diff --git a/ByeBoo-iOS/ByeBoo-iOS.xcodeproj/project.pbxproj b/ByeBoo-iOS/ByeBoo-iOS.xcodeproj/project.pbxproj index 814438d8..2387f417 100644 --- a/ByeBoo-iOS/ByeBoo-iOS.xcodeproj/project.pbxproj +++ b/ByeBoo-iOS/ByeBoo-iOS.xcodeproj/project.pbxproj @@ -329,7 +329,7 @@ PRODUCT_BUNDLE_IDENTIFIER = "com.heartz.ByeBoo-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match Development com.heartz.ByeBoo-iOS"; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match Development com.heartz.ByeBoo-iOS 1780581426"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; @@ -349,7 +349,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "ByeBoo-iOS/ByeBoo-Prod.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 10; DEVELOPMENT_TEAM = ""; @@ -372,7 +372,7 @@ PRODUCT_BUNDLE_IDENTIFIER = "com.heartz.ByeBoo-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AppStore com.heartz.ByeBoo-iOS"; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match Development com.heartz.ByeBoo-iOS 1780581426"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; diff --git a/ByeBoo-iOS/ByeBoo-iOS/App/AppDelegate.swift b/ByeBoo-iOS/ByeBoo-iOS/App/AppDelegate.swift index 1bce5697..5ab4cde4 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/App/AppDelegate.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/App/AppDelegate.swift @@ -48,7 +48,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { _ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data ) { - guard let notificationRepository = DIContainer.shared.resolve(type: DefaultNotificationRepository.self) else { + guard let notificationRepository = DIContainer.shared.resolve(type: DefaultNotificationTokenRepository.self) else { return } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Data/DataDependencyAssembler.swift b/ByeBoo-iOS/ByeBoo-iOS/Data/DataDependencyAssembler.swift index 4304c2bb..d5393fbd 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Data/DataDependencyAssembler.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Data/DataDependencyAssembler.swift @@ -67,6 +67,10 @@ struct DataDependencyAssembler: DependencyAssembler { DIContainer.shared.register(type: ReportsInterface.self) { _ in return DefaultReportsRepository(networkService: networkService) } + + DIContainer.shared.register(type: NotificationInterface.self) { _ in + return DefaultNotificationRepository(networkService: networkService) + } } } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Data/Enum/NotificationType+Data.swift b/ByeBoo-iOS/ByeBoo-iOS/Data/Enum/NotificationType+Data.swift new file mode 100644 index 00000000..c1cb649c --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Data/Enum/NotificationType+Data.swift @@ -0,0 +1,24 @@ +// +// NotificationType+Data.swift +// ByeBoo-iOS +// +// Created by 더스틴 on 6/7/26. +// + +extension NotificationType { + + var responseKey: String { + switch self { + case .questOpen: + "QUEST_OPEN" + case .comment: + "COMMENT" + case .like: + "LIKE" + } + } + + static func keyToEnum(_ key: String) -> Self? { + allCases.first { $0.responseKey == key } + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Data/Model/HasUnreadNotificationResponseDTO.swift b/ByeBoo-iOS/ByeBoo-iOS/Data/Model/HasUnreadNotificationResponseDTO.swift new file mode 100644 index 00000000..fdc8f184 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Data/Model/HasUnreadNotificationResponseDTO.swift @@ -0,0 +1,16 @@ +// +// HasUnreadNotificationResponseDTO.swift +// ByeBoo-iOS +// +// Created by 더스틴 on 6/19/26. +// + +struct HasUnreadNotificationResponseDTO: Decodable { + let hasUnread: Bool +} + +extension HasUnreadNotificationResponseDTO { + func toEntity() -> HasUnreadNotificationEntity { + .init(hasUnread: hasUnread) + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Data/Model/NotificationListResponseDTO.swift b/ByeBoo-iOS/ByeBoo-iOS/Data/Model/NotificationListResponseDTO.swift new file mode 100644 index 00000000..9c2cb343 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Data/Model/NotificationListResponseDTO.swift @@ -0,0 +1,41 @@ +// +// NotificationListResponseDTO.swift +// ByeBoo-iOS +// +// Created by 더스틴 on 6/5/26. +// + +struct NotificationListResponseDTO: Decodable { + let notifications: [NotificationResponseDTO] +} + +struct NotificationResponseDTO: Decodable { + let notificationID: Int + let notificationType: String + let title: String + let content: String + let isRead: Bool + let createdAt: String + let landingURL: String +} + +extension NotificationListResponseDTO { + func toEntity() -> NotificationListEntity { + let notifications = notifications.map { $0.toEntity() } + return .init(notifications: notifications) + } +} + +extension NotificationResponseDTO { + func toEntity() -> NotificationEntity { + .init( + notificationID: notificationID, + notificationType: NotificationType.keyToEnum(notificationType), + title: title, + content: content, + isRead: isRead, + createdAt: createdAt, + landingURL: landingURL + ) + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Data/Network/EndPoint/NotificationAPI.swift b/ByeBoo-iOS/ByeBoo-iOS/Data/Network/EndPoint/NotificationAPI.swift index c77364c0..97dc1a49 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Data/Network/EndPoint/NotificationAPI.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Data/Network/EndPoint/NotificationAPI.swift @@ -2,7 +2,7 @@ // NotificationAPI.swift // ByeBoo-iOS // -// Created by APPLE on 11/22/25. +// Created by 더스틴 on 6/5/26. // import Foundation @@ -10,60 +10,57 @@ import Foundation import Alamofire enum NotificationAPI { - case saveToken(dto: FCMTokenDTO) - case updateToken(dto: FCMTokenDTO) - case deleteToken(dto: FCMTokenDTO) + case fetchNotificationList + case fetchUnreadNotification } extension NotificationAPI: EndPoint { - + var basePath: String { - return "/api/v1/notification-tokens" + return "/api/v1/notifications" } var path: String { switch self { - case .saveToken, .updateToken, .deleteToken: - return "" + case .fetchNotificationList: + "" + case .fetchUnreadNotification: + "/unread/status" } } var method: HTTPMethod { switch self { - case .saveToken: - return .post - case .updateToken: - return .patch - case .deleteToken: - return .put + case .fetchNotificationList, .fetchUnreadNotification: + .get } } var headers: HeaderType { switch self { - case .saveToken, .updateToken, .deleteToken: - return .withAuth + case .fetchNotificationList, .fetchUnreadNotification: + .withAuth } } var parameterEncoding: ParameterEncoding { switch self { - case .saveToken, .updateToken, .deleteToken: - return JSONEncoding.default + case .fetchNotificationList, .fetchUnreadNotification: + JSONEncoding.default } } var queryParameters: [String : String]? { switch self { - case .saveToken, .updateToken, .deleteToken: - return nil + case .fetchNotificationList, .fetchUnreadNotification: + nil } } var bodyParameters: Parameters? { switch self { - case .saveToken(let dto), .updateToken(let dto), .deleteToken(let dto): - return try? dto.toDictionary() + case .fetchNotificationList ,.fetchUnreadNotification: + nil } } } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Data/Network/EndPoint/NotificationTokenAPI.swift b/ByeBoo-iOS/ByeBoo-iOS/Data/Network/EndPoint/NotificationTokenAPI.swift new file mode 100644 index 00000000..ea727164 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Data/Network/EndPoint/NotificationTokenAPI.swift @@ -0,0 +1,69 @@ +// +// NotificationTokenAPI.swift +// ByeBoo-iOS +// +// Created by APPLE on 11/22/25. +// + +import Foundation + +import Alamofire + +enum NotificationTokenAPI { + case saveToken(dto: FCMTokenDTO) + case updateToken(dto: FCMTokenDTO) + case deleteToken(dto: FCMTokenDTO) +} + +extension NotificationTokenAPI: EndPoint { + + var basePath: String { + return "/api/v1/notification-tokens" + } + + var path: String { + switch self { + case .saveToken, .updateToken, .deleteToken: + return "" + } + } + + var method: HTTPMethod { + switch self { + case .saveToken: + return .post + case .updateToken: + return .patch + case .deleteToken: + return .put + } + } + + var headers: HeaderType { + switch self { + case .saveToken, .updateToken, .deleteToken: + return .withAuth + } + } + + var parameterEncoding: ParameterEncoding { + switch self { + case .saveToken, .updateToken, .deleteToken: + return JSONEncoding.default + } + } + + var queryParameters: [String : String]? { + switch self { + case .saveToken, .updateToken, .deleteToken: + return nil + } + } + + var bodyParameters: Parameters? { + switch self { + case .saveToken(let dto), .updateToken(let dto), .deleteToken(let dto): + return try? dto.toDictionary() + } + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Data/Repository/AuthRepository.swift b/ByeBoo-iOS/ByeBoo-iOS/Data/Repository/AuthRepository.swift index c25722e2..394f3506 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Data/Repository/AuthRepository.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Data/Repository/AuthRepository.swift @@ -89,7 +89,7 @@ struct DefaultAuthRepository: AuthInterface { let fcmToken = try await Messaging.messaging().token() let fcmTokenDTO = FCMTokenDTO(token: fcmToken) try await network.request( - NotificationAPI.saveToken(dto: fcmTokenDTO) + NotificationTokenAPI.saveToken(dto: fcmTokenDTO) ) } catch (let error) { ByeBooLogger.error(error) @@ -127,7 +127,7 @@ struct DefaultAuthRepository: AuthInterface { return false } try await network.request( - NotificationAPI.deleteToken(dto: .init(token: fcmToken)) + NotificationTokenAPI.deleteToken(dto: .init(token: fcmToken)) ) } catch (let error) { diff --git a/ByeBoo-iOS/ByeBoo-iOS/Data/Repository/NotificationRepository.swift b/ByeBoo-iOS/ByeBoo-iOS/Data/Repository/NotificationRepository.swift index 4a136854..91419050 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Data/Repository/NotificationRepository.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Data/Repository/NotificationRepository.swift @@ -1,115 +1,31 @@ // -// NotificationRepositoru.swift +// NotificationRepository.swift // ByeBoo-iOS // -// Created by APPLE on 11/22/25. +// Created by 더스틴 on 6/5/26. // struct DefaultNotificationRepository: NotificationInterface { - private let network: NetworkService - private let userDefaultsService: UserDefaultService - private let keychainService: KeychainService + private let networkService: NetworkService - init( - network: NetworkService, - userDefaultsService: UserDefaultService, - keychainService: KeychainService - ) { - self.network = network - self.userDefaultsService = userDefaultsService - self.keychainService = keychainService + init(networkService: NetworkService) { + self.networkService = networkService } - func loadToken() -> String? { - guard let token: String = userDefaultsService.load(key: .fcmToken) else { - return nil - } - return token - } - - func sendToken(token: String) async throws { - let accessToken = keychainService.load(key: .accessToken) - - guard !accessToken.isEmpty else { - saveToken(token: token) - return - } - - let fcmTokenDTO = createDTO(token: token) - try await network.request( - NotificationAPI.saveToken(dto: fcmTokenDTO) - ) - saveToken(token: token) - } - - func updateToken(token: String) async throws { - let accessToken = keychainService.load(key: .accessToken) - - guard !accessToken.isEmpty else { - saveToken(token: token) - return - } - - let fcmTokenDTO = createDTO(token: token) - try await network.request( - NotificationAPI.updateToken(dto: fcmTokenDTO) + func fetchNotifications() async throws -> NotificationListEntity { + let result = try await networkService.request( + NotificationAPI.fetchNotificationList, + decodingType: NotificationListResponseDTO.self ) - saveToken(token: token) - } - - func saveToken(token: String) { - let _ = userDefaultsService.save(token, key: .fcmToken) + return result.toEntity() } - func deleteToken(token: String) async throws { - let fcmTokenDTO = createDTO(token: token) - let accessToken = keychainService.load(key: .accessToken) - try await network.request( - NotificationAPI.deleteToken(dto: fcmTokenDTO) + func fetchHasUnreadNotification() async throws -> HasUnreadNotificationEntity { + let result = try await networkService.request( + NotificationAPI.fetchUnreadNotification, + decodingType: HasUnreadNotificationResponseDTO.self ) - let _ = userDefaultsService.delete(key: .fcmToken) - } - - private func createDTO(token: String) -> FCMTokenDTO { - .init(token: token) - } -} - -final class MockNotificationRepository: NotificationInterface { - - private let userDefaultsService: UserDefaultService - var sendTokenCalled = false - var updateTokenCalled = false - var deleteTokenCalled = false - - init(userDefaultsService: UserDefaultService) { - self.userDefaultsService = userDefaultsService - } - - func loadToken() -> String? { - guard let token: String = userDefaultsService.load(key: .fcmToken) else { - return nil - } - return token - } - - func sendToken(token: String) { - sendTokenCalled = true - saveToken(token: token) - } - - func saveToken(token: String) { - let _ = userDefaultsService.save(token, key: .fcmToken) - } - - func updateToken(token: String) { - updateTokenCalled = true - let _ = userDefaultsService.save(token, key: .fcmToken) - } - - func deleteToken(token: String) { - deleteTokenCalled = true - let _ = userDefaultsService.delete(key: .fcmToken) + return result.toEntity() } } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Data/Repository/NotificationTokenRepository.swift b/ByeBoo-iOS/ByeBoo-iOS/Data/Repository/NotificationTokenRepository.swift new file mode 100644 index 00000000..98b3050d --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Data/Repository/NotificationTokenRepository.swift @@ -0,0 +1,115 @@ +// +// NotificationTokenRepository.swift +// ByeBoo-iOS +// +// Created by APPLE on 11/22/25. +// + +struct DefaultNotificationTokenRepository: NotificationTokenInterface { + + private let network: NetworkService + private let userDefaultsService: UserDefaultService + private let keychainService: KeychainService + + init( + network: NetworkService, + userDefaultsService: UserDefaultService, + keychainService: KeychainService + ) { + self.network = network + self.userDefaultsService = userDefaultsService + self.keychainService = keychainService + } + + func loadToken() -> String? { + guard let token: String = userDefaultsService.load(key: .fcmToken) else { + return nil + } + return token + } + + func sendToken(token: String) async throws { + let accessToken = keychainService.load(key: .accessToken) + + guard !accessToken.isEmpty else { + saveToken(token: token) + return + } + + let fcmTokenDTO = createDTO(token: token) + try await network.request( + NotificationTokenAPI.saveToken(dto: fcmTokenDTO) + ) + saveToken(token: token) + } + + func updateToken(token: String) async throws { + let accessToken = keychainService.load(key: .accessToken) + + guard !accessToken.isEmpty else { + saveToken(token: token) + return + } + + let fcmTokenDTO = createDTO(token: token) + try await network.request( + NotificationTokenAPI.updateToken(dto: fcmTokenDTO) + ) + saveToken(token: token) + } + + func saveToken(token: String) { + let _ = userDefaultsService.save(token, key: .fcmToken) + } + + func deleteToken(token: String) async throws { + let fcmTokenDTO = createDTO(token: token) + let accessToken = keychainService.load(key: .accessToken) + try await network.request( + NotificationTokenAPI.deleteToken(dto: fcmTokenDTO) + ) + let _ = userDefaultsService.delete(key: .fcmToken) + } + + private func createDTO(token: String) -> FCMTokenDTO { + .init(token: token) + } +} + +final class MockNotificationTokenRepository: NotificationTokenInterface { + + private let userDefaultsService: UserDefaultService + var sendTokenCalled = false + var updateTokenCalled = false + var deleteTokenCalled = false + + init(userDefaultsService: UserDefaultService) { + self.userDefaultsService = userDefaultsService + } + + func loadToken() -> String? { + guard let token: String = userDefaultsService.load(key: .fcmToken) else { + return nil + } + return token + } + + func sendToken(token: String) { + sendTokenCalled = true + saveToken(token: token) + } + + func saveToken(token: String) { + let _ = userDefaultsService.save(token, key: .fcmToken) + } + + func updateToken(token: String) { + updateTokenCalled = true + let _ = userDefaultsService.save(token, key: .fcmToken) + } + + func deleteToken(token: String) { + deleteTokenCalled = true + let _ = userDefaultsService.delete(key: .fcmToken) + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Domain/DomainDependencyAssembler.swift b/ByeBoo-iOS/ByeBoo-iOS/Domain/DomainDependencyAssembler.swift index b79e9a4f..8eeb3e5d 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Domain/DomainDependencyAssembler.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Domain/DomainDependencyAssembler.swift @@ -23,10 +23,11 @@ struct DomainDependencyAssembler: DependencyAssembler { let forbiddenWordRepository = DIContainer.shared.resolve(type: ForbiddenWordInterface.self), let commonQuestRepository = DIContainer.shared.resolve(type: CommonQuestInterface.self), let blocksRepository = DIContainer.shared.resolve(type: BlocksInterface.self), - let reportsRepository = DIContainer.shared.resolve(type: ReportsInterface.self) else { - ByeBooLogger.error(ByeBooError.DIFailedError) - return - } + let reportsRepository = DIContainer.shared.resolve(type: ReportsInterface.self), + let notificationRepository = DIContainer.shared.resolve(type: NotificationInterface.self) else { + ByeBooLogger.error(ByeBooError.DIFailedError) + return + } DIContainer.shared.register(type: FetchUserJourneyUseCase.self) { _ in return DefaultFetchUserJourneyUseCase(repository: userRepository) @@ -171,7 +172,7 @@ struct DomainDependencyAssembler: DependencyAssembler { DIContainer.shared.register(type: FetchCommonQuestMyAnswersUseCase.self) { _ in return DefaultFetchCommonQuestMyAnswersUseCase(repository: userRepository) } - + DIContainer.shared.register(type: FetchAIAnswerUseCase.self) { _ in return DefaultFetchAIAnswerUseCase(repository: questRepository) } @@ -187,7 +188,7 @@ struct DomainDependencyAssembler: DependencyAssembler { DIContainer.shared.register(type: UpdateCommonQuestUseCase.self) { _ in return DefaultUpdateCommonQuestUseCase(repository: commonQuestRepository) } - + DIContainer.shared.register(type: BlockUserUseCase.self) { _ in return DefaultBlockUserCase(repository: blocksRepository) } @@ -207,5 +208,17 @@ struct DomainDependencyAssembler: DependencyAssembler { DIContainer.shared.register(type: DeleteCommonQuestUseCase.self) { _ in return DefaultDeleteCommonQuestUseCase(repository: commonQuestRepository) } + + DIContainer.shared.register(type: FetchNotificationListUseCase.self) { _ in + return DefaultFetchNotificationListUseCase(repository: notificationRepository) + } + + DIContainer.shared.register(type: FormatElapsedTimeUseCase.self) { _ in + return DefaultFormatElapsedTimeUseCase() + } + + DIContainer.shared.register(type: FetchHasUnreadNotificationUseCase.self) { _ in + return DefaultFetchHasUnreadNotificationUseCase(repository: notificationRepository) + } } } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Domain/Entity/Enum/NotificationType.swift b/ByeBoo-iOS/ByeBoo-iOS/Domain/Entity/Enum/NotificationType.swift new file mode 100644 index 00000000..6fba51e5 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Domain/Entity/Enum/NotificationType.swift @@ -0,0 +1,12 @@ +// +// NotificationType.swift +// ByeBoo-iOS +// +// Created by 더스틴 on 6/7/26. +// + +enum NotificationType: CaseIterable { + case questOpen + case comment + case like +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Domain/Entity/HasUnreadNotificationEntity.swift b/ByeBoo-iOS/ByeBoo-iOS/Domain/Entity/HasUnreadNotificationEntity.swift new file mode 100644 index 00000000..ce047066 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Domain/Entity/HasUnreadNotificationEntity.swift @@ -0,0 +1,10 @@ +// +// HasUnreadNotificationEntity.swift +// ByeBoo-iOS +// +// Created by 더스틴 on 6/19/26. +// + +struct HasUnreadNotificationEntity { + let hasUnread: Bool +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Domain/Entity/NotificationListEntity.swift b/ByeBoo-iOS/ByeBoo-iOS/Domain/Entity/NotificationListEntity.swift new file mode 100644 index 00000000..fd4dcc33 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Domain/Entity/NotificationListEntity.swift @@ -0,0 +1,110 @@ +// +// NotificationListEntity.swift +// ByeBoo-iOS +// +// Created by 더스틴 on 6/5/26. +// + +struct NotificationListEntity { + let notifications: [NotificationEntity] +} + +struct NotificationEntity { + let notificationID: Int + let notificationType: NotificationType? + let title: String + let content: String + let isRead: Bool + let createdAt: String + let landingURL: String +} + +extension NotificationListEntity { + static func stub() -> Self { + .init( + notifications: [ + NotificationEntity( + notificationID: 9, + notificationType: .questOpen, + title: "오늘의 퀘스트 오픈 🌱", + content: "24번째 퀘스트가 오픈됐어요, 시작해볼까요?", + isRead: false, + createdAt: "2026-06-07T11:29:00.735730", + landingURL: "myapp://quest/24" + ), + NotificationEntity( + notificationID: 8, + notificationType: .comment, + title: "공통여정에 답변이 달렸어요 💬", + content: "내가 작성한 글에 보리보리쌀님이 답변을 남겼어요", + isRead: true, + createdAt: "2026-06-07T08:35:43.735730", + landingURL: "myapp://common-quests/24" + ), + NotificationEntity( + notificationID: 7, + notificationType: .like, + title: "공통여정에 답변에 공감이 달렸어요 ❤️", + content: "내가 작성한 글에 보리보리쌀님이 공감을 남겼어요", + isRead: true, + createdAt: "2026-06-06T15:35:43.735730", + landingURL: "myapp://common-quests/23" + ), + NotificationEntity( + notificationID: 6, + notificationType: .questOpen, + title: "오늘의 퀘스트 오픈 🌱", + content: "24번째 퀘스트가 오픈됐어요, 시작해볼까요?", + isRead: false, + createdAt: "2026-02-19T02:09:43.735730", + landingURL: "myapp://quest/24" + ), + NotificationEntity( + notificationID: 5, + notificationType: .comment, + title: "공통여정에 답변이 달렸어요 💬", + content: "내가 작성한 글에 보리보리쌀님이 답변을 남겼어요", + isRead: true, + createdAt: "2026-02-19T02:09:43.735730", + landingURL: "myapp://common-quests/24" + ), + NotificationEntity( + notificationID: 4, + notificationType: .like, + title: "공통여정에 답변에 공감이 달렸어요 ❤️", + content: "내가 작성한 글에 보리보리쌀님이 공감을 남겼어요", + isRead: true, + createdAt: "2026-02-19T02:09:43.735730", + landingURL: "myapp://common-quests/23" + ), + NotificationEntity( + notificationID: 3, + notificationType: .questOpen, + title: "오늘의 퀘스트 오픈 🌱", + content: "24번째 퀘스트가 오픈됐어요, 시작해볼까요?", + isRead: false, + createdAt: "2026-02-19T02:09:43.735730", + landingURL: "myapp://quest/24" + ), + NotificationEntity( + notificationID: 2, + notificationType: .comment, + title: "공통여정에 답변이 달렸어요 💬", + content: "내가 작성한 글에 보리보리쌀님이 답변을 남겼어요", + isRead: true, + createdAt: "2026-02-19T02:09:43.735730", + landingURL: "myapp://common-quests/24" + ), + NotificationEntity( + notificationID: 1, + notificationType: .like, + title: "공통여정에 답변에 공감이 달렸어요 ❤️", + content: "내가 작성한 글에 보리보리쌀님이 공감을 남겼어요", + isRead: true, + createdAt: "2026-02-19T02:09:43.735730", + landingURL: "myapp://common-quests/23" + ) + ] + ) + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Domain/Interface/NotificationInterface.swift b/ByeBoo-iOS/ByeBoo-iOS/Domain/Interface/NotificationInterface.swift index 05cc0709..3640970f 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Domain/Interface/NotificationInterface.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Domain/Interface/NotificationInterface.swift @@ -2,13 +2,10 @@ // NotificationInterface.swift // ByeBoo-iOS // -// Created by APPLE on 11/26/25. +// Created by 더스틴 on 6/5/26. // protocol NotificationInterface { - func loadToken() -> String? - func sendToken(token: String) async throws - func saveToken(token: String) - func updateToken(token: String) async throws - func deleteToken(token: String) async throws + func fetchNotifications() async throws -> NotificationListEntity + func fetchHasUnreadNotification() async throws -> HasUnreadNotificationEntity } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Domain/Interface/NotificationTokenInterface.swift b/ByeBoo-iOS/ByeBoo-iOS/Domain/Interface/NotificationTokenInterface.swift new file mode 100644 index 00000000..3c92561a --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Domain/Interface/NotificationTokenInterface.swift @@ -0,0 +1,14 @@ +// +// NotificationInterface.swift +// ByeBoo-iOS +// +// Created by APPLE on 11/26/25. +// + +protocol NotificationTokenInterface { + func loadToken() -> String? + func sendToken(token: String) async throws + func saveToken(token: String) + func updateToken(token: String) async throws + func deleteToken(token: String) async throws +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Domain/UseCase/FetchHasUnreadNotificationUseCase.swift b/ByeBoo-iOS/ByeBoo-iOS/Domain/UseCase/FetchHasUnreadNotificationUseCase.swift new file mode 100644 index 00000000..cde37b86 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Domain/UseCase/FetchHasUnreadNotificationUseCase.swift @@ -0,0 +1,24 @@ +// +// FetchHasUnreadNotificationUseCase.swift +// ByeBoo-iOS +// +// Created by 더스틴 on 6/19/26. +// + +protocol FetchHasUnreadNotificationUseCase { + func execute() async throws -> HasUnreadNotificationEntity +} + +struct DefaultFetchHasUnreadNotificationUseCase: FetchHasUnreadNotificationUseCase { + + private let repository: NotificationInterface + + init(repository: NotificationInterface) { + self.repository = repository + } + + func execute() async throws -> HasUnreadNotificationEntity { + let result = try await repository.fetchHasUnreadNotification() + return result + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Domain/UseCase/FetchNotificationListUseCase.swift b/ByeBoo-iOS/ByeBoo-iOS/Domain/UseCase/FetchNotificationListUseCase.swift new file mode 100644 index 00000000..bb0dec12 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Domain/UseCase/FetchNotificationListUseCase.swift @@ -0,0 +1,23 @@ +// +// FetchNotificationListUseCase.swift +// ByeBoo-iOS +// +// Created by 더스틴 on 6/5/26. +// + +protocol FetchNotificationListUseCase { + func execute() async throws -> NotificationListEntity +} + +struct DefaultFetchNotificationListUseCase: FetchNotificationListUseCase { + + private let repository: NotificationInterface + + init(repository: NotificationInterface) { + self.repository = repository + } + + func execute() async throws -> NotificationListEntity { + try await repository.fetchNotifications() + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Domain/UseCase/FormatElapsedTimeUseCase.swift b/ByeBoo-iOS/ByeBoo-iOS/Domain/UseCase/FormatElapsedTimeUseCase.swift new file mode 100644 index 00000000..23d14196 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Domain/UseCase/FormatElapsedTimeUseCase.swift @@ -0,0 +1,40 @@ +// +// FormatElapsedTimeUseCase.swift +// ByeBoo-iOS +// +// Created by 더스틴 on 6/7/26. +// + +import Foundation + +protocol FormatElapsedTimeUseCase { + func execute(from timeString: String) -> String? +} + +struct DefaultFormatElapsedTimeUseCase: FormatElapsedTimeUseCase { + + private let minute: Double = 60 + private let hour: Double = 3600 + private let day: Double = 86400 + + func execute(from timeString: String) -> String? { + guard let time = DateFormatter.toDetailDate(from: timeString) else { + return nil + } + + let diffTime = Date().timeIntervalSince(time) + + switch diffTime { + case .. UIImage { + switch notificationType { + case .questOpen: + return .myOn + case .comment, .like: + return .commonJourney + } } } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/ViewController/HomeViewController.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/ViewController/HomeViewController.swift index 89abd74c..09fa25a3 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/ViewController/HomeViewController.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/ViewController/HomeViewController.swift @@ -22,7 +22,6 @@ final class HomeViewController: BaseViewController { private var isFirstVisit: Bool = true private var journeyType: JourneyType = .recording private var isAnimating: Bool = false - private var isExistNotice: Bool? init(viewModel: HomeViewModel) { self.viewModel = viewModel @@ -48,6 +47,9 @@ final class HomeViewController: BaseViewController { super.viewWillAppear(animated) viewModel.action(.viewWillAppear) + if isFirstVisit { isFirstVisit.toggle() } + + self.navigationController?.setNavigationBarHidden(true, animated: false) let property = HomeEvents.HomePageProperty( isFirstPageView: isFirstVisit, @@ -57,13 +59,6 @@ final class HomeViewController: BaseViewController { event: HomeEvents.Name.homePageView, properties: property.dictionary ) - - if isFirstVisit { isFirstVisit.toggle() } - - self.navigationController?.setNavigationBarHidden(true, animated: false) - // 추후 API 연동 시 뷰모델 액션을 호출하여 알림 유무를 판단할 예정 - rootView.headerView.updateNotice(isExist: true) - isExistNotice = true } override func setAddTarget() { @@ -122,9 +117,7 @@ extension HomeViewController { @objc private func noticeButtonDidTap() { - guard let isExistNotice else { return } - - let viewController = ViewControllerFactory.shared.makeNoticesViewController(isExistNotice: isExistNotice) + let viewController = ViewControllerFactory.shared.makeNotificationsViewController() viewController.hidesBottomBarWhenPushed = true self.navigationController?.setNavigationBarHidden(false, animated: false) @@ -152,6 +145,13 @@ extension HomeViewController { extension HomeViewController: ToastPresentable, ToastErrorHandler { private func bind() { + bindCharacter() + bindHomeState() + bindHelper() + bindHasNotification() + } + + private func bindCharacter() { viewModel.output.characterResult .receive(on: DispatchQueue.main) .sink { [weak self] result in @@ -163,7 +163,9 @@ extension HomeViewController: ToastPresentable, ToastErrorHandler { } } .store(in: &cancellables) - + } + + private func bindHomeState() { Publishers.CombineLatest3( viewModel.output.userResult, viewModel.output.journeyResult, @@ -193,7 +195,9 @@ extension HomeViewController: ToastPresentable, ToastErrorHandler { } } .store(in: &cancellables) - + } + + private func bindHelper() { viewModel.output.helperResult .receive(on: DispatchQueue.main) .sink { [weak self] result in @@ -203,4 +207,18 @@ extension HomeViewController: ToastPresentable, ToastErrorHandler { } .store(in: &cancellables) } + + private func bindHasNotification() { + viewModel.output.hasNotifcationResult + .receive(on: DispatchQueue.main) + .sink { [weak self] result in + switch result { + case .success(let entity): + self?.rootView.headerView.updateNotice(isExist: entity.hasUnread) + case .failure(let error): + self?.handleError(error) + } + } + .store(in: &cancellables) + } } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/ViewController/NoticesViewController.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/ViewController/NoticesViewController.swift deleted file mode 100644 index 78046246..00000000 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/ViewController/NoticesViewController.swift +++ /dev/null @@ -1,114 +0,0 @@ -// -// NoticesViewController.swift -// ByeBoo-iOS -// -// Created by APPLE on 4/30/26. -// - -import UIKit - -final class NoticesViewController: BaseViewController { - - private let isExistNotice: Bool - private let rootView = NoticesView() - - init(isExistNotice: Bool) { - self.isExistNotice = isExistNotice - super.init(nibName: nil, bundle: nil) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func loadView() { - view = rootView - } - - override func viewDidLoad() { - super.viewDidLoad() - - ByeBooNavigationBar.makeNavigationBar( - navigationItem: self.navigationItem, - navigationController: self.navigationController, - type: .titleAndBack("알림"), - action: #selector(back) - ) - - rootView.contentView.decideNoticeContent(isExistNotice: isExistNotice) - } - - override func setAddTarget() { - rootView.readAllButton.addTarget( - self, - action: #selector(readAllButtonDidTap), - for: .touchUpInside - ) - } - - override func setDelegate() { - rootView.contentView.noticeCardsView.cardTableView.do { - $0.dataSource = self - $0.delegate = self - $0.register(NoticeCardCell.self) - } - } -} - -extension NoticesViewController: BackNavigable { - - func back() { - self.navigationController?.popViewController(animated: false) - } -} - -extension NoticesViewController { - - @objc - private func readAllButtonDidTap() {} -} - -extension NoticesViewController: UITableViewDataSource { - - func numberOfSections(in tableView: UITableView) -> Int { - // 실제로는 알림 개수만큼 설정 - 2 - } - - func tableView( - _ tableView: UITableView, - numberOfRowsInSection section: Int - ) -> Int { - 1 - } - - func tableView( - _ tableView: UITableView, - cellForRowAt indexPath: IndexPath - ) -> UITableViewCell { - - guard let cell = tableView.dequeueReusableCell(withIdentifier: NoticeCardCell.identifier, for: indexPath) as? NoticeCardCell - else { - return UITableViewCell() - } - - return cell - } -} - -extension NoticesViewController: UITableViewDelegate { - - func tableView( - _ tableView: UITableView, - viewForHeaderInSection section: Int - ) -> UIView? { - UIView() - } - - func tableView( - _ tableView: UITableView, - heightForHeaderInSection section: Int - ) -> CGFloat { - 0 - } -} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/ViewController/NotificationsViewController.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/ViewController/NotificationsViewController.swift new file mode 100644 index 00000000..954fb040 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/ViewController/NotificationsViewController.swift @@ -0,0 +1,153 @@ +// +// NotificationsViewController.swift +// ByeBoo-iOS +// +// Created by APPLE on 4/30/26. +// + +import Combine +import UIKit + +final class NotificationsViewController: BaseViewController { + + private let rootView = NoticesView() + private let viewModel: NotificationsViewModel + private var cancellables = Set() + + init(viewModel: NotificationsViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + view = rootView + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + viewModel.action(.viewWillAppear) + } + + override func viewDidLoad() { + super.viewDidLoad() + + ByeBooNavigationBar.makeNavigationBar( + navigationItem: self.navigationItem, + navigationController: self.navigationController, + type: .titleAndBack("알림"), + action: #selector(back) + ) + + bind() + } + + override func setAddTarget() { + rootView.readAllButton.addTarget( + self, + action: #selector(readAllButtonDidTap), + for: .touchUpInside + ) + } + + override func setDelegate() { + rootView.contentView.noticeCardsView.cardTableView.do { + $0.dataSource = self + $0.delegate = self + $0.register(NoticeCardCell.self) + } + } +} + +extension NotificationsViewController: BackNavigable { + + func back() { + self.navigationController?.popViewController(animated: false) + } +} + +extension NotificationsViewController: ToastPresentable, ToastErrorHandler { + + func bind() { + viewModel.output.notificationList + .receive(on: DispatchQueue.main) + .sink { [weak self] result in + switch result { + case .success(let notificationList): + self?.rootView.contentView.decideNoticeContent(isExistNotice: !notificationList.notifications.isEmpty) + self?.rootView.contentView.noticeCardsView.cardTableView.reloadData() + case .failure(let error): + self?.handleError(error) + } + } + .store(in: &cancellables) + } +} + +extension NotificationsViewController { + + @objc + private func readAllButtonDidTap() {} +} + +extension NotificationsViewController: UITableViewDataSource { + + func numberOfSections(in tableView: UITableView) -> Int { + viewModel.notificatinosCount + } + + func tableView( + _ tableView: UITableView, + numberOfRowsInSection section: Int + ) -> Int { + 1 + } + + func tableView( + _ tableView: UITableView, + cellForRowAt indexPath: IndexPath + ) -> UITableViewCell { + + guard + let cell = tableView.dequeueReusableCell( + withIdentifier: NoticeCardCell.identifier, + for: indexPath + ) as? NoticeCardCell, + let notification = viewModel.getNotification(at: indexPath.section), + let notificationType = notification.notificationType, + let writtenTime = viewModel.formatElapsedTime(from: notification.createdAt) + else { + return UITableViewCell() + } + + cell.bind( + isRead: notification.isRead, + notificationType: notificationType, + title: notification.title, + subtitle: notification.content, + writtenTime: writtenTime + ) + + return cell + } +} + +extension NotificationsViewController: UITableViewDelegate { + + func tableView( + _ tableView: UITableView, + viewForHeaderInSection section: Int + ) -> UIView? { + UIView() + } + + func tableView( + _ tableView: UITableView, + heightForHeaderInSection section: Int + ) -> CGFloat { + 0 + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/ViewModel/HomeViewModel.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/ViewModel/HomeViewModel.swift index 18458a7e..36fd3a65 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/ViewModel/HomeViewModel.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/ViewModel/HomeViewModel.swift @@ -22,13 +22,15 @@ final class HomeViewModel { private var isHelperShownResultSubject = CurrentValueSubject(true) private var homeStateResultSubject = PassthroughSubject, Never>() private var journeyResultSubject = PassthroughSubject, Never>() - + private var hasNotificationResultSubject = PassthroughSubject,Never>() + private let fetchCharacterDialogueUseCase: FetchCharacterDialogueUseCase private let fetchQuestStatusUseCase: FetchQuestStatusUseCase private let fetchUserJourneyUseCase: FetchUserJourneyUseCase private let getUserNameUseCase: GetUserNameUseCase private let setHelperUseCase: SetHelperUseCase private let getHelperUseCase: GetHelperUseCase + private let fetchHasUnreadNotificationUseCase: FetchHasUnreadNotificationUseCase init( fetchCharacterDialogueUseCase: FetchCharacterDialogueUseCase, @@ -36,7 +38,8 @@ final class HomeViewModel { fetchUserJourneyUseCase: FetchUserJourneyUseCase, getUserNameUseCase: GetUserNameUseCase, setHelperUseCase: SetHelperUseCase, - getHelperUseCase: GetHelperUseCase + getHelperUseCase: GetHelperUseCase, + fetchHasUnreadNotificationUseCase: FetchHasUnreadNotificationUseCase ) { self.fetchCharacterDialogueUseCase = fetchCharacterDialogueUseCase self.fetchQuestStatusUseCase = fetchQuestStatusUseCase @@ -44,13 +47,15 @@ final class HomeViewModel { self.getUserNameUseCase = getUserNameUseCase self.setHelperUseCase = setHelperUseCase self.getHelperUseCase = getHelperUseCase + self.fetchHasUnreadNotificationUseCase = fetchHasUnreadNotificationUseCase output = Output( characterResult: characterResultSubject.eraseToAnyPublisher(), userResult: userResultSubject.eraseToAnyPublisher(), helperResult: isHelperShownResultSubject.eraseToAnyPublisher(), homeStateResult: homeStateResultSubject.eraseToAnyPublisher(), - journeyResult: journeyResultSubject.eraseToAnyPublisher() + journeyResult: journeyResultSubject.eraseToAnyPublisher(), + hasNotifcationResult: hasNotificationResultSubject.eraseToAnyPublisher() ) } } @@ -67,17 +72,23 @@ extension HomeViewModel: ViewModelType { let helperResult: AnyPublisher let homeStateResult: AnyPublisher, Never> let journeyResult: AnyPublisher, Never> + let hasNotifcationResult: AnyPublisher, Never> } func action(_ trigger: Input) { switch trigger { case .viewWillAppear: - // TODO: 구조적 동시성 반영 - fetchDialogue() - fetchStatus() - fetchJourney() - getUserResult() + + Task { + async let dialogue: Void = fetchDialogue() + async let status: Void = fetchStatus() + async let journey: Void = fetchJourney() + async let hasNotification: Void = fetchHasUnreadNotification() + + let _ = await (dialogue, status, journey) + } + case .helperDidTap: setHelperShown() } @@ -85,49 +96,43 @@ extension HomeViewModel: ViewModelType { } extension HomeViewModel { - private func fetchDialogue() { - Task { - do { - let dialogues = try await fetchCharacterDialogueUseCase.execute() - characterResultSubject.send(.success(dialogues)) - } catch { - characterResultSubject.send( - .failure( - error as? ByeBooError ?? ByeBooError.unknownError - ) + private func fetchDialogue() async { + do { + let dialogues = try await fetchCharacterDialogueUseCase.execute() + characterResultSubject.send(.success(dialogues)) + } catch { + characterResultSubject.send( + .failure( + error as? ByeBooError ?? ByeBooError.unknownError ) - } + ) } } - private func fetchStatus() { - Task { - do { - let status = try await fetchQuestStatusUseCase.execute() - homeStateResultSubject.send(.success(status)) - isHelperShown(state: status.currentStatus) - ByeBooLogger.debug("home status: \(status)") - } catch { - if let error = error as? ByeBooError { - homeStateResultSubject.send(.failure(error)) - } - isHelperShown(state: .beforeJourneyStart) + private func fetchStatus() async { + do { + let status = try await fetchQuestStatusUseCase.execute() + homeStateResultSubject.send(.success(status)) + isHelperShown(state: status.currentStatus) + ByeBooLogger.debug("home status: \(status)") + } catch { + if let error = error as? ByeBooError { + homeStateResultSubject.send(.failure(error)) } + isHelperShown(state: .beforeJourneyStart) } } - private func fetchJourney() { - Task { - do { - let journey = try await fetchUserJourneyUseCase.execute() - journeyResultSubject.send(.success(journey)) - } catch { - journeyResultSubject.send( - .failure( - error as? ByeBooError ?? ByeBooError.unknownError - ) + private func fetchJourney() async { + do { + let journey = try await fetchUserJourneyUseCase.execute() + journeyResultSubject.send(.success(journey)) + } catch { + journeyResultSubject.send( + .failure( + error as? ByeBooError ?? ByeBooError.unknownError ) - } + ) } } @@ -137,7 +142,6 @@ extension HomeViewModel { } private func isHelperShown(state: HomeState) { - if !getHelperUseCase.execute() && state == .beforeJourneyStart { isHelperShownResultSubject.send(false) } else { @@ -148,4 +152,17 @@ extension HomeViewModel { private func setHelperShown() { setHelperUseCase.execute() } + + private func fetchHasUnreadNotification() async { + do { + let result = try await fetchHasUnreadNotificationUseCase.execute() + hasNotificationResultSubject.send(.success(result)) + } catch { + hasNotificationResultSubject.send( + .failure( + error as? ByeBooError ?? ByeBooError.unknownError + ) + ) + } + } } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/ViewModel/NotificationsViewModel.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/ViewModel/NotificationsViewModel.swift new file mode 100644 index 00000000..0cf4afa3 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/ViewModel/NotificationsViewModel.swift @@ -0,0 +1,79 @@ +// +// NotificationsViewModel.swift +// ByeBoo-iOS +// +// Created by 더스틴 on 6/7/26. +// + +import Combine + +final class NotificationsViewModel { + + private var cancellables = Set() + private var notifications: [NotificationEntity]? + + private let fetchNotificationListUseCase: FetchNotificationListUseCase + private let formatElapsedTimeUseCase: FormatElapsedTimeUseCase + + private(set) var output: Output + private var notificationListSubject = PassthroughSubject, Never>() + + init( + fetchNotificationListUseCase: FetchNotificationListUseCase, + formatElapsedTimeUseCase: FormatElapsedTimeUseCase + ) { + self.fetchNotificationListUseCase = fetchNotificationListUseCase + self.formatElapsedTimeUseCase = formatElapsedTimeUseCase + self.output = .init(notificationList: notificationListSubject.eraseToAnyPublisher()) + } +} + +extension NotificationsViewModel: ViewModelType { + enum Input { + case viewWillAppear + } + + struct Output { + let notificationList: AnyPublisher, Never> + } + + func action(_ trigger: Input) { + switch trigger { + case .viewWillAppear: + fetchNotificationList() + } + } +} + +extension NotificationsViewModel { + + func fetchNotificationList() { + Task { + do { + let notificationList = try await fetchNotificationListUseCase.execute() + self.notifications = notificationList.notifications + notificationListSubject.send(.success(notificationList)) + } catch { + guard let error = error as? ByeBooError else { + return + } + notificationListSubject.send(.failure(error)) + } + } + } + + func formatElapsedTime(from timeString: String) -> String? { + formatElapsedTimeUseCase.execute(from: timeString) + } +} + +extension NotificationsViewModel { + + var notificatinosCount: Int { + notifications?.count ?? 0 + } + + func getNotification(at index: Int) -> NotificationEntity? { + notifications?[index] + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewModel/CommonQuestMyAnswerViewModel.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewModel/CommonQuestMyAnswerViewModel.swift index 7622a3cb..63cba471 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewModel/CommonQuestMyAnswerViewModel.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewModel/CommonQuestMyAnswerViewModel.swift @@ -13,6 +13,7 @@ final class CommonQuestMyAnswerViewModel { private let cancellables = Set() private let nameSubject = PassthroughSubject, Never>.init() private let answersSubject = PassthroughSubject, Never>.init() + private let getUserNameUseCase: GetUserNameUseCase private let fetchCommonQuestMyAnswersUseCase: FetchCommonQuestMyAnswersUseCase diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewModel/CommonQuestViewModel.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewModel/CommonQuestViewModel.swift index 82f1dc2b..a196aec7 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewModel/CommonQuestViewModel.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewModel/CommonQuestViewModel.swift @@ -13,9 +13,7 @@ final class CommonQuestViewModel { private let cancellables = Set() private let commonQuestSubject = PassthroughSubject, Never>.init() private let fetchCommonQuestByDateUseCase: FetchCommonQuestByDateUseCase - private let minute: Double = 60 - private let hour: Double = 3600 - private let day: Double = 86400 + private let formatElapsedTimeUseCase: FormatElapsedTimeUseCase private(set) var output: Output private var commonQuest: CommonQuestAnswersEntity? @@ -24,8 +22,12 @@ final class CommonQuestViewModel { private var nextCursor: Int? = nil private var currentDate: String = DateFormatter.toAPIDateString(from: .now) - init(fetchCommonQuestByDateUseCase: FetchCommonQuestByDateUseCase) { + init( + fetchCommonQuestByDateUseCase: FetchCommonQuestByDateUseCase, + formatElapsedTimeUseCase: FormatElapsedTimeUseCase + ) { self.fetchCommonQuestByDateUseCase = fetchCommonQuestByDateUseCase + self.formatElapsedTimeUseCase = formatElapsedTimeUseCase self.output = Output( commonQuestPublisher: commonQuestSubject.eraseToAnyPublisher() ) @@ -127,25 +129,6 @@ extension CommonQuestViewModel { } func getWrittenAt(at index: Int) -> String? { - guard index >= 0 && index < answers.count, - let writtenAt = DateFormatter.toDetailDate(from: answers[index].writtenAt) - else { - return nil - } - - let diffTime = Date().timeIntervalSince(writtenAt) - - switch diffTime { - case .. WriteActiveTypeQuestViewController func makeFinishJourneyViewController() -> FinishJourneyViewController func makeCommonQuestBottomSheetViewController() -> CommonQuestBottomSheetViewController + func makeCompletedQuestsViewController() -> CompletedQuestsViewController + func makeParentQuestViewController() -> ParentQuestViewController + func makeCommonQuestViewController() -> CommonQuestViewController + func makeCommonQuestHistoryViewController() -> CommonQuestHistoryViewController + func makeCommonQuestMyAnswersViewController() -> CommonQuestMyAnswersViewController + func makeBlockedUserListViewController() -> BlockedkUserListViewController + func makeAIAnswerViewController() -> AIAnswerViewController + func makeNotificationsViewController() -> NotificationsViewController } final class ViewControllerFactory: ViewControllerFactoryProtocol { @@ -225,8 +233,13 @@ final class ViewControllerFactory: ViewControllerFactoryProtocol { return .init(viewModel: viewModel) } - func makeNoticesViewController(isExistNotice: Bool) -> NoticesViewController { - .init(isExistNotice: isExistNotice) + func makeNotificationsViewController() -> NotificationsViewController { + guard let viewModel = DIContainer.shared.resolve(type: NotificationsViewModel.self) else { + DIErrorHandle() + fatalError() + } + + return .init(viewModel: viewModel) } }