diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..b2c9e3a4 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @y-eonee @dev-domo @juri123123 diff --git a/.github/workflows/build_test.yml b/.github/workflows/build_test.yml index e7798b88..e1edab52 100644 --- a/.github/workflows/build_test.yml +++ b/.github/workflows/build_test.yml @@ -7,7 +7,7 @@ on: jobs: build: - runs-on: macos-latest + runs-on: macos-15 steps: - name: Checkout code diff --git a/ByeBoo-iOS/ByeBoo-iOS.xcodeproj/project.pbxproj b/ByeBoo-iOS/ByeBoo-iOS.xcodeproj/project.pbxproj index 0bc2de35..de2d12b6 100644 --- a/ByeBoo-iOS/ByeBoo-iOS.xcodeproj/project.pbxproj +++ b/ByeBoo-iOS/ByeBoo-iOS.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 183EE0E12ED1D10600154F40 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = 183EE0E02ED1D10600154F40 /* FirebaseMessaging */; }; 186847F02ED5567D002827DA /* FirebaseCore in Frameworks */ = {isa = PBXBuildFile; productRef = 186847EF2ED5567D002827DA /* FirebaseCore */; }; 18A8172D2E154F3D00F68F9F /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = 18A8172C2E154F3D00F68F9F /* Alamofire */; }; + 514D021B2FF6ADDD0041A4E1 /* FirebaseRemoteConfig in Frameworks */ = {isa = PBXBuildFile; productRef = 514D021A2FF6ADDD0041A4E1 /* FirebaseRemoteConfig */; }; 515C127F2E159D45001BA2E0 /* Then in Frameworks */ = {isa = PBXBuildFile; productRef = 515C127E2E159D45001BA2E0 /* Then */; }; 515C12822E159D77001BA2E0 /* SnapKit in Frameworks */ = {isa = PBXBuildFile; productRef = 515C12812E159D77001BA2E0 /* SnapKit */; }; 51691DED2E5CB059008CCEC6 /* KakaoSDKAuth in Frameworks */ = {isa = PBXBuildFile; productRef = 51691DEC2E5CB059008CCEC6 /* KakaoSDKAuth */; }; @@ -77,6 +78,7 @@ CBB032A72E7925FC00C0855E /* Mixpanel in Frameworks */, 51691DED2E5CB059008CCEC6 /* KakaoSDKAuth in Frameworks */, CB71A8A32F3B6F9400176B64 /* FirebaseCrashlytics in Frameworks */, + 514D021B2FF6ADDD0041A4E1 /* FirebaseRemoteConfig in Frameworks */, 183EE0E12ED1D10600154F40 /* FirebaseMessaging in Frameworks */, 183EE0DB2ED1CB0E00154F40 /* FirebaseAnalytics in Frameworks */, ); @@ -151,6 +153,7 @@ 183EE0E02ED1D10600154F40 /* FirebaseMessaging */, 186847EF2ED5567D002827DA /* FirebaseCore */, CB71A8A22F3B6F9400176B64 /* FirebaseCrashlytics */, + 514D021A2FF6ADDD0041A4E1 /* FirebaseRemoteConfig */, ); productName = "ByeBoo-iOS"; productReference = CB41808B2E0925D2001E7BCB /* ByeBoo-iOS.app */; @@ -307,7 +310,7 @@ CODE_SIGN_ENTITLEMENTS = "ByeBoo-iOS/ByeBoo-Dev.entitlements"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 18; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = Z6682N5G5D; @@ -325,7 +328,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.3; + MARKETING_VERSION = 1.3.0; PRODUCT_BUNDLE_IDENTIFIER = "com.heartz.ByeBoo-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -349,9 +352,9 @@ 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 = 1; + CURRENT_PROJECT_VERSION = 18; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = Z6682N5G5D; GENERATE_INFOPLIST_FILE = YES; @@ -368,11 +371,11 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.3; + MARKETING_VERSION = 1.3.0; 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"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; @@ -512,7 +515,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 10; + CURRENT_PROJECT_VERSION = 15; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.2; MACOSX_DEPLOYMENT_TARGET = 15.2; @@ -534,7 +537,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 10; + CURRENT_PROJECT_VERSION = 15; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.2; MACOSX_DEPLOYMENT_TARGET = 15.2; @@ -671,6 +674,11 @@ package = 18A8172B2E154F3D00F68F9F /* XCRemoteSwiftPackageReference "Alamofire" */; productName = Alamofire; }; + 514D021A2FF6ADDD0041A4E1 /* FirebaseRemoteConfig */ = { + isa = XCSwiftPackageProductDependency; + package = 183EE0D92ED1CB0E00154F40 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseRemoteConfig; + }; 515C127E2E159D45001BA2E0 /* Then */ = { isa = XCSwiftPackageProductDependency; package = 515C127D2E159D45001BA2E0 /* XCRemoteSwiftPackageReference "Then" */; 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/Core/ByeBooError.swift b/ByeBoo-iOS/ByeBoo-iOS/Core/ByeBooError.swift index 70136271..e4af7dc7 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Core/ByeBooError.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Core/ByeBooError.swift @@ -28,6 +28,7 @@ enum ByeBooError: Error, LocalizedError, Equatable { case fileNotFound case nicknameViolation case questViolation + case dateFormatError var errorDescription: String? { switch self { @@ -71,6 +72,8 @@ enum ByeBooError: Error, LocalizedError, Equatable { return "비속어나 부적절한 단어가 포함된 닉네임" case .questViolation: return "비속어나 부적절한 단어가 포함된 답변" + case .dateFormatError: + return "날짜 문자열 형식이 올바르지 않음" } } } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Data/DataDependencyAssembler.swift b/ByeBoo-iOS/ByeBoo-iOS/Data/DataDependencyAssembler.swift index 4304c2bb..38543a4b 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Data/DataDependencyAssembler.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Data/DataDependencyAssembler.swift @@ -67,6 +67,16 @@ 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) + } + DIContainer.shared.register(type: CommentInterface.self) { _ in + return DefaultCommentRepository( + network: networkService, + userDefaultService: userDefaultService + ) + } } } 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/CommonQuestAnswerDetailResponseDTO.swift b/ByeBoo-iOS/ByeBoo-iOS/Data/Model/CommonQuestAnswerDetailResponseDTO.swift new file mode 100644 index 00000000..b4ab2523 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Data/Model/CommonQuestAnswerDetailResponseDTO.swift @@ -0,0 +1,76 @@ +// +// CommonQuestAnswerDetailResponseDTO.swift +// ByeBoo-iOS +// +// Created by 이나연 on 5/5/26. +// + +import Foundation + +struct CommonQuestAnswerDetailResponseDTO: Decodable { + let question: String + let answer: CommonQuestAnswerSimpleResponseDTO + let comments: [CommonQuestCommentResponseDTO] +} + +struct CommonQuestAnswerSimpleResponseDTO: Decodable { + let content: String + let writerId: Int + let writer: String + let profileIcon: String + let likeCount: Int + let commentCount: Int + let isLiked: Bool + let writtenAt: String +} + +struct CommonQuestCommentResponseDTO: Decodable { + let commentId: Int + let replyCount: Int + let writerId: Int + let writer: String + let profileIcon: String + let writtenAt: String + let content: String +} + +extension CommonQuestAnswerDetailResponseDTO { + func toEntity(userID: Int) -> CommonQuestDetailEntity { + .init( + question: question, + answer: answer.toEntity(userID: userID), + comments: comments.map { $0.toEntity(userID: userID) } + ) + } +} + +extension CommonQuestAnswerSimpleResponseDTO { + func toEntity(userID: Int) -> CommonQuestAnswerDetailEntity { + .init( + isMyAnswer: userID == writerId ? true : false, + content: content, + writerID: writerId, + writer: writer, + profileIcon: profileIcon, + likeCount: likeCount, + commentCount: commentCount, + isLiked: isLiked, + writtenAt: writtenAt + ) + } +} + +extension CommonQuestCommentResponseDTO { + func toEntity(userID: Int) -> CommonQuestCommentEntity { + return .init( + isMyComment: userID == writerId ? true : false, + commentID: commentId, + replyCount: replyCount, + writerID: writerId, + writer: writer, + profileIcon: profileIcon, + writtenAt: writtenAt, + content: content + ) + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Data/Model/CommonQuestAnswerRepliesResponseDTO.swift b/ByeBoo-iOS/ByeBoo-iOS/Data/Model/CommonQuestAnswerRepliesResponseDTO.swift new file mode 100644 index 00000000..fd5a96bd --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Data/Model/CommonQuestAnswerRepliesResponseDTO.swift @@ -0,0 +1,47 @@ +// +// CommonQuestAnswerRepliesResponseDTO.swift +// ByeBoo-iOS +// +// Created by 이나연 on 5/5/26. +// + +import Foundation + +struct CommonQuestAnswerRepliesResponseDTO: Decodable { + let totalCount: Int + let comment: CommonQuestAnswerCommentResponseDTO + let replies: [CommonQuestAnswerCommentResponseDTO] +} + +struct CommonQuestAnswerCommentResponseDTO: Decodable { + let content: String + let writer: String + let createdAt: String + let profileIcon: String + let commentId: Int + let writerId: Int +} + +extension CommonQuestAnswerRepliesResponseDTO { + func toEntity(userID: Int) -> CommonQuestReplyListEntity { + .init( + totalCount: totalCount, + replies: replies.map { $0.toEntity(userID: userID) } + ) + } +} + +extension CommonQuestAnswerCommentResponseDTO { + func toEntity(userID: Int) -> CommonQuestCommentEntity { + .init( + isMyComment: userID == writerId ? true : false, + commentID: commentId, + replyCount: nil, + writerID: writerId, + writer: writer, + profileIcon: profileIcon, + writtenAt: createdAt, + content: content + ) + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Data/Model/CommonQuestAnswersResponseDTO.swift b/ByeBoo-iOS/ByeBoo-iOS/Data/Model/CommonQuestAnswersResponseDTO.swift index 8c3e1aae..2f0e05dd 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Data/Model/CommonQuestAnswersResponseDTO.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Data/Model/CommonQuestAnswersResponseDTO.swift @@ -16,16 +16,19 @@ struct CommonQuestAnswersResponseDTO: Decodable { } struct CommonQuestAnswerResponseDTO: Decodable { + let likeCount: Int + let commentCount: Int + let isLiked: Bool let answerId: Int + let writerId: Int let writer: String let profileIcon: String let writtenAt: String let content: String - let writerId: Int } extension CommonQuestAnswersResponseDTO { - func toEntity(userName: String) -> CommonQuestAnswersEntity { + func toEntity(userID: Int) -> CommonQuestAnswersEntity { .init( question: question, questID: questId, @@ -33,21 +36,24 @@ extension CommonQuestAnswersResponseDTO { isAnswered: isAnswered, hasNext: hasNext, nextCursor: nil, - answers: answers.map { $0.toEntity(userName: userName) } + answers: answers.map { $0.toEntity(userID: userID) } ) } } extension CommonQuestAnswerResponseDTO { - func toEntity(userName: String) -> CommonQuestAnswerEntity { + func toEntity(userID: Int) -> CommonQuestAnswerEntity { .init( - isMyAnswer: userName == writer ? true : false, + isMyAnswer: userID == writerId ? true : false, answerID: answerId, + writerID: writerId, writer: writer, profileIcon: profileIcon, writtenAt: writtenAt, content: content, - writerID: writerId + likeCount: likeCount, + commentCount: commentCount, + isLiked: isLiked ) } } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Data/Model/CommonQuestCommentRequestDTO.swift b/ByeBoo-iOS/ByeBoo-iOS/Data/Model/CommonQuestCommentRequestDTO.swift new file mode 100644 index 00000000..faeebacd --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Data/Model/CommonQuestCommentRequestDTO.swift @@ -0,0 +1,13 @@ +// +// CommonQuestCommentRequestDTO.swift +// ByeBoo-iOS +// +// Created by 이나연 on 6/14/26. +// + +import Foundation + +struct CommonQuestCommentRequestDTO: Encodable { + let content: String + let targetId: Int +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Data/Model/CommonQuestMyAnswersResponseDTO.swift b/ByeBoo-iOS/ByeBoo-iOS/Data/Model/CommonQuestMyAnswersResponseDTO.swift index 2a1ad8fb..58ee68db 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Data/Model/CommonQuestMyAnswersResponseDTO.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Data/Model/CommonQuestMyAnswersResponseDTO.swift @@ -16,15 +16,18 @@ struct CommonQuestMyAnswerResponseDTO: Decodable { let writtenAt: String let content: String let question: String + let likeCount: Int + let commentCount: Int + let isLiked: Bool } extension CommonQuestMyAnswersResponseDTO { - func toEntity() -> CommonQuestMyAnswersEntity { + func toEntity(userID: Int) -> CommonQuestMyAnswersEntity { .init( hasNext: hasNext, nextCursor: nextCursor, - answers: answers.map { $0.toEntity() - }) + answers: answers.map { $0.toEntity() } + ) } } @@ -34,7 +37,10 @@ extension CommonQuestMyAnswerResponseDTO { answerID: answerId, writtenAt: writtenAt, content: content, - question: question + question: question, + isLiked: isLiked, + likeCount: likeCount, + commentCount: commentCount ) } } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Data/Model/CommonQuestReplyRequestDTO.swift b/ByeBoo-iOS/ByeBoo-iOS/Data/Model/CommonQuestReplyRequestDTO.swift new file mode 100644 index 00000000..87a48405 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Data/Model/CommonQuestReplyRequestDTO.swift @@ -0,0 +1,12 @@ +// +// CommonQuestReplyRequestDTO.swift +// ByeBoo-iOS +// +// Created by 이나연 on 6/14/26. +// + +import Foundation + +struct CommonQuestReplyRequestDTO: Encodable { + let content: String +} 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..0bead5b2 --- /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 content: String + let title: String + let isRead: Bool + let createdAt: String + let landingUrl: String + let notificationType: 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/Model/PostCommonQuestLikeResponseDTO.swift b/ByeBoo-iOS/ByeBoo-iOS/Data/Model/PostCommonQuestLikeResponseDTO.swift new file mode 100644 index 00000000..4370f012 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Data/Model/PostCommonQuestLikeResponseDTO.swift @@ -0,0 +1,19 @@ +// +// postCommonQuestLikeResponseDTO.swift +// ByeBoo-iOS +// +// Created by 이나연 on 6/10/26. +// + +import Foundation + +struct PostCommonQuestLikeResponseDTO: Decodable { + let likeCount: Int + let isLiked: Bool +} + +extension PostCommonQuestLikeResponseDTO { + func toEntity() -> CommonQuestLikeEntity { + .init(isLiked: isLiked, likeCount: likeCount) + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Data/Model/ReportRequestDTO.swift b/ByeBoo-iOS/ByeBoo-iOS/Data/Model/ReportRequestDTO.swift new file mode 100644 index 00000000..b99f56c6 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Data/Model/ReportRequestDTO.swift @@ -0,0 +1,13 @@ +// +// ReportRequestDTO.swift +// ByeBoo-iOS +// +// Created by 이나연 on 6/16/26. +// + +import Foundation + +struct ReportRequestDTO: Encodable { + let targetType: String + let targetId: Int +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Data/Model/Util/ServerDateFormatter.swift b/ByeBoo-iOS/ByeBoo-iOS/Data/Model/Util/ServerDateFormatter.swift index 5c70828d..e7514006 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Data/Model/Util/ServerDateFormatter.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Data/Model/Util/ServerDateFormatter.swift @@ -12,12 +12,15 @@ final class ServerDateFormatter { static let shared = ServerDateFormatter() private let formatter: DateFormatter + private static let minute: TimeInterval = 60 + private static let hour: TimeInterval = 3600 + private static let day: TimeInterval = 86400 + private init() { self.formatter = DateFormatter() formatter.do { $0.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" $0.locale = Locale(identifier: "en_US_POSIX") - $0.timeZone = TimeZone(secondsFromGMT: 0) } } @@ -25,4 +28,23 @@ final class ServerDateFormatter { guard let string = string else { return nil } return formatter.date(from: string) } + + func relativeTimeString(from dateString: String) -> String? { + guard let date = formatter.date(from: dateString) else { return nil } + + let diff = Date().timeIntervalSince(date) + + switch diff { + case .. 토큰 재발급 성공") completion(.retry) - } catch { + } catch(let error as ByeBooError) { completion(.doNotRetryWithError(error)) } } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Data/Persistence/Service/ForceUpdateManager.swift b/ByeBoo-iOS/ByeBoo-iOS/Data/Persistence/Service/ForceUpdateManager.swift new file mode 100644 index 00000000..cc4a0be2 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Data/Persistence/Service/ForceUpdateManager.swift @@ -0,0 +1,40 @@ +// +// RemoteConfigeManager.swift +// ByeBoo-iOS +// +// Created by 이나연 on 7/2/26. +// + +import FirebaseRemoteConfig + +final class DefaultForceUpdateService: ForceUpdateInterface { + let remoteConfig = RemoteConfig.remoteConfig() + + init() { + let settings = RemoteConfigSettings() + settings.minimumFetchInterval = 0 + remoteConfig.configSettings = settings + } + + func checkForUpdate() async -> Bool { + do { + try await remoteConfig.fetch() + try await remoteConfig.activate() + + let minimumVersion = remoteConfig["min_version_code"].stringValue + + return isUpdateRequired(minimumVersion: minimumVersion) + } catch { + return false + } + } + + private var currentAppVersion: String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0" + } + + private func isUpdateRequired(minimumVersion: String) -> Bool { + ByeBooLogger.debug("현재버전: \(currentAppVersion), 최소버전: \(minimumVersion)") + return currentAppVersion.compare(minimumVersion, options: .numeric) == .orderedAscending + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Data/Persistence/UserDefaultsKey.swift b/ByeBoo-iOS/ByeBoo-iOS/Data/Persistence/UserDefaultsKey.swift index d9432c00..741bdc25 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Data/Persistence/UserDefaultsKey.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Data/Persistence/UserDefaultsKey.swift @@ -10,7 +10,6 @@ import Foundation enum UserDefaultsKey: String, CaseIterable { case userName case userID - case isOnboardingCompleted case isHelperShown case isRegistered case loginPlatform diff --git a/ByeBoo-iOS/ByeBoo-iOS/Data/Repository/AuthRepository.swift b/ByeBoo-iOS/ByeBoo-iOS/Data/Repository/AuthRepository.swift index b558ea0d..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) @@ -97,12 +97,12 @@ struct DefaultAuthRepository: AuthInterface { } func autoLogin() async throws -> Bool { - let isOnboardingCompleted: Bool = userDefaultsService.load(key: .isOnboardingCompleted) ?? false - ByeBooLogger.debug("온보딩 여부 \(isOnboardingCompleted)") + let isRegistered: Bool = userDefaultsService.load(key: .isRegistered) ?? false + ByeBooLogger.debug("온보딩 여부 \(isRegistered)") if !keychainService.load(key: .accessToken).isEmpty && !keychainService.load(key: .refreshToken).isEmpty - && isOnboardingCompleted + && isRegistered { ByeBooLogger.debug("정보 있음") try await tokenService.reissue() @@ -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) { @@ -136,7 +136,7 @@ struct DefaultAuthRepository: AuthInterface { } clearKeychain() - removeUserInfo(excludedKeys: [.isOnboardingCompleted, .isHelperShown, .hasEnterMyPage]) + removeUserInfo(excludedKeys: [.isRegistered, .isHelperShown, .hasEnterMyPage]) return true } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Data/Repository/CommentRepository.swift b/ByeBoo-iOS/ByeBoo-iOS/Data/Repository/CommentRepository.swift new file mode 100644 index 00000000..cf63af1f --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Data/Repository/CommentRepository.swift @@ -0,0 +1,50 @@ +// +// CommentRepository.swift +// ByeBoo-iOS +// +// Created by 이나연 on 6/14/26. +// + +import Foundation + +struct DefaultCommentRepository: CommentInterface { + + private let network: NetworkService + private let userDefaultServcie: UserDefaultService + + init( + network: NetworkService, + userDefaultService: UserDefaultService + ) { + self.network = network + self.userDefaultServcie = userDefaultService + } + + func postComment(content: String, answerID: Int) async throws { + let postCommentRequestDTO: CommonQuestCommentRequestDTO = .init(content: content, targetId: answerID) + let _ = try await network.request(CommentAPI.postComment(dto: postCommentRequestDTO)) + } + + func postReply(content: String, commentID: Int) async throws { + let postReplyRequestDTO: CommonQuestReplyRequestDTO = .init(content: content) + let _ = try await network.request(CommentAPI.postReply(commentID: commentID, dto: postReplyRequestDTO)) + } + + func fetchReplies(commentID: Int) async throws -> CommonQuestReplyListEntity { + let userID: Int = userDefaultServcie.load(key: .userID) ?? 0 + let result = try await network.request( + CommentAPI.fetchReplies(commentID: commentID), + decodingType: CommonQuestAnswerRepliesResponseDTO.self + ) + return result.toEntity(userID: userID) + } + + func patchComment(content: String, commentID: Int) async throws { + let patchCommentRequestDTO: CommonQuestReplyRequestDTO = .init(content: content) + let _ = try await network.request(CommentAPI.patchComment(commentID: commentID, dto: patchCommentRequestDTO)) + } + + func deleteComment(commentID: Int) async throws { + let _ = try await network.request(CommentAPI.deleteComment(commentID: commentID)) + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Data/Repository/CommonQuestRepository.swift b/ByeBoo-iOS/ByeBoo-iOS/Data/Repository/CommonQuestRepository.swift index c74198ca..8cfe7511 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Data/Repository/CommonQuestRepository.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Data/Repository/CommonQuestRepository.swift @@ -38,7 +38,7 @@ struct DefaultCommonQuestRepository: CommonQuestInterface { date: String, cursor: Int? ) async throws -> CommonQuestAnswersEntity { - let userName: String = userDefaultsService.load(key: .userName) ?? "" + let userID: Int = userDefaultsService.load(key: .userID) ?? 0 let commonQuest = try await network.request( CommonQuestAPI.fetchCommonQuest( date: date, @@ -46,7 +46,7 @@ struct DefaultCommonQuestRepository: CommonQuestInterface { ), decodingType: CommonQuestAnswersResponseDTO.self ) - return commonQuest.toEntity(userName: userName) + return commonQuest.toEntity(userID: userID) } func updateCommonQuest(answerID: Int, answer: String) async throws { @@ -64,4 +64,22 @@ struct DefaultCommonQuestRepository: CommonQuestInterface { CommonQuestAPI.deleteCommonQuest(answerID: answerID) ) } + + func fetchCommonQuestDetail(answerID: Int) async throws -> CommonQuestDetailEntity { + let userID: Int = userDefaultsService.load(key: .userID) ?? 0 + let commonQuestDetail = try await network.request( + CommonQuestAPI.fetchCommonQuestDetail(answerID: answerID), + decodingType: CommonQuestAnswerDetailResponseDTO.self + ) + + return commonQuestDetail.toEntity(userID: userID) + } + + func postCommonQuestLikes(answerID: Int) async throws -> CommonQuestLikeEntity { + let response = try await network.request( + CommonQuestAPI.postCommonQuestLike(answerID: answerID), + decodingType: PostCommonQuestLikeResponseDTO.self + ) + return response.toEntity() + } } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Data/Repository/NotificationRepository.swift b/ByeBoo-iOS/ByeBoo-iOS/Data/Repository/NotificationRepository.swift index 4a136854..b50b7030 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Data/Repository/NotificationRepository.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Data/Repository/NotificationRepository.swift @@ -1,115 +1,39 @@ // -// 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) + return result.toEntity() } - 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( - 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) + return result.toEntity() } - func updateToken(token: String) { - updateTokenCalled = true - let _ = userDefaultsService.save(token, key: .fcmToken) + func readNotification(for notificationID: Int) async throws { + try await networkService.request(NotificationAPI.readNotification(notificationID: notificationID)) } - func deleteToken(token: String) { - deleteTokenCalled = true - let _ = userDefaultsService.delete(key: .fcmToken) + func readAllNotifications() async throws { + try await networkService.request(NotificationAPI.readAllNotifications) } } 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/Data/Repository/QuestsRepository.swift b/ByeBoo-iOS/ByeBoo-iOS/Data/Repository/QuestsRepository.swift index 8aebd63b..8baf24ac 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Data/Repository/QuestsRepository.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Data/Repository/QuestsRepository.swift @@ -122,24 +122,23 @@ struct DefaultQuestRepository: QuestsInterface { .emptyAnswerStub() } - func fetchAIAnswer(questID: Int, isAnswerExists: Bool) async throws -> AIAnswerEntity { - let result: AIAnswerResponseDTO - - if isAnswerExists { - result = try await network.request( - QuestAPI.fetchAIAnswer(questID: questID), - decodingType: AIAnswerResponseDTO.self - ) - } else { - result = try await network.request( - QuestAPI.createAIAnswer(questID: questID), - decodingType: AIAnswerResponseDTO.self - ) - } + func fetchAIAnswer(questID: Int) async throws -> AIAnswerEntity { + let result: AIAnswerResponseDTO = try await network.request( + QuestAPI.createAIAnswer(questID: questID), + decodingType: AIAnswerResponseDTO.self + ) return result.toEntity() } + func createAIAnswer(questID: Int) async throws -> AIAnswerEntity { + let result = try await network.request( + QuestAPI.createAIAnswer(questID: questID), + decodingType: AIAnswerResponseDTO.self + ) + return result.toEntity() + } + // MARK: private function private func makeSignedURL(imageKey: String) async throws -> String { @@ -188,14 +187,6 @@ struct DefaultQuestRepository: QuestsInterface { QuestAPI.editActive(questID: questID, request: editQuestActiveDTO) ) } - - private func createAIAnswer(questID: Int) async throws -> AIAnswerEntity { - let result = try await network.request( - QuestAPI.createAIAnswer(questID: questID), - decodingType: AIAnswerResponseDTO.self - ) - return result.toEntity() - } } final class MockQuestsRepository: QuestsInterface { @@ -258,9 +249,12 @@ final class MockQuestsRepository: QuestsInterface { .emptyAnswerStub() } - func fetchAIAnswer(questID: Int, isAnswerExists: Bool) async throws -> AIAnswerEntity { + func fetchAIAnswer(questID: Int) async throws -> AIAnswerEntity { + .stub() + } + + func createAIAnswer(questID: Int) async throws -> AIAnswerEntity { try await Task.sleep(for: .seconds(2)) - throw ByeBooError.unknownError -// .stub() + return .stub() } } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Data/Repository/ReportsRepository.swift b/ByeBoo-iOS/ByeBoo-iOS/Data/Repository/ReportsRepository.swift index 3c1fd9ad..7b492ec2 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Data/Repository/ReportsRepository.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Data/Repository/ReportsRepository.swift @@ -15,9 +15,9 @@ struct DefaultReportsRepository: ReportsInterface { self.networkService = networkService } - func reportCommonQuest(answerID: Int) async throws { - let _ = try await networkService.request( - ReportsAPI.postReport(answerID: answerID) - ) + func reportCommonQuest(targetID: Int, targetType: CommonQuestTargetType) async throws { + let reportRequestDTO: ReportRequestDTO = .init(targetType: targetType.rawValue, targetId: targetID) + ByeBooLogger.debug("\(targetID), \(targetType)") + _ = try await networkService.request(ReportsAPI.postReport(dto: reportRequestDTO)) } } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Data/Repository/UsersRepository.swift b/ByeBoo-iOS/ByeBoo-iOS/Data/Repository/UsersRepository.swift index 8d3e6a01..e7db2260 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Data/Repository/UsersRepository.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Data/Repository/UsersRepository.swift @@ -50,7 +50,7 @@ struct DefaultUsersRepository: UsersInterface { ) let _ = userDefaultsService.save(result.id, key: .userID) let _ = userDefaultsService.save(result.name, key: .userName) - let _ = userDefaultsService.save(true, key: .isOnboardingCompleted) + let _ = userDefaultsService.save(true, key: .isRegistered) let _ = userDefaultsService.save(false, key: .hasEnterMyPage) let _ = userDefaultsService.save(false, key: .alarmEnabled) @@ -144,11 +144,12 @@ struct DefaultUsersRepository: UsersInterface { } func fetchMyCommonQuestAnswers(cursor: Int?) async throws -> CommonQuestMyAnswersEntity { + let userID: Int = userDefaultsService.load(key: .userID) ?? 0 let result = try await network.request( UsersAPI.fetchCommonQuestAnswers(cursor: cursor), decodingType: CommonQuestMyAnswersResponseDTO.self ) - return result.toEntity() + return result.toEntity(userID: userID) } var alarmEnabled: Bool { diff --git a/ByeBoo-iOS/ByeBoo-iOS/Domain/DomainDependencyAssembler.swift b/ByeBoo-iOS/ByeBoo-iOS/Domain/DomainDependencyAssembler.swift index b79e9a4f..648e046e 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Domain/DomainDependencyAssembler.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Domain/DomainDependencyAssembler.swift @@ -23,7 +23,9 @@ 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 { + let notificationRepository = DIContainer.shared.resolve(type: NotificationInterface.self), + let reportsRepository = DIContainer.shared.resolve(type: ReportsInterface.self), + let commentRepository = DIContainer.shared.resolve(type: CommentInterface.self) else { ByeBooLogger.error(ByeBooError.DIFailedError) return } @@ -171,7 +173,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 +189,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 +209,55 @@ struct DomainDependencyAssembler: DependencyAssembler { DIContainer.shared.register(type: DeleteCommonQuestUseCase.self) { _ in return DefaultDeleteCommonQuestUseCase(repository: commonQuestRepository) } + + DIContainer.shared.register(type: FetchCommonQuestDetailUseCase.self) { _ in + return DefaultFetchCommonQuestDetailUseCase(repository: commonQuestRepository) + } + DIContainer.shared.register(type: PostCommonQuestCommentUseCase.self) { _ in + return DefaultPostCommonQuestCommentUseCase(repository: commentRepository) + } + DIContainer.shared.register(type: PostCommonQuestReplyUseCase.self) { _ in + return DefaultPostCommonQuestReplyUseCase(repository: commentRepository) + } + + DIContainer.shared.register(type: PostCommonQuestLikeUseCase.self) { _ in + return DefaultPostCommonQuestLikeUseCase(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: ReadNotificationUseCase.self) { _ in + return DefaultReadNotificationUseCase(repository: notificationRepository) + } + + DIContainer.shared.register(type: FetchHasUnreadNotificationUseCase.self) { _ in + return DefaultFetchHasUnreadNotificationUseCase(repository: notificationRepository) + } + + DIContainer.shared.register(type: FetchCommonQuestRepliesUseCase.self) { _ in + return DefaultFetchCommonQuestRepliesUseCase(repository: commentRepository) + } + + DIContainer.shared.register(type: DeleteCommentReplyUseCase.self) { _ in + return DefaultDeleteCommentReplyUseCase(repository: commentRepository) + } + + DIContainer.shared.register(type: EditCommentReplyUseCase.self) { _ in + return DefaultEditCommentReplyUseCase(repository: commentRepository) + } + + DIContainer.shared.register(type: ReadAllNotificationsUseCase.self) { _ in + return DefaultReadAllNotificationsUseCase(repository: notificationRepository) + } + + DIContainer.shared.register(type: CheckForceUpdateUseCase.self) { _ in + return DefaultCheckForceUpdateUseCase(repository: DefaultForceUpdateService()) + } } } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Domain/Entity/CommonQuestAnswersEntity.swift b/ByeBoo-iOS/ByeBoo-iOS/Domain/Entity/CommonQuestAnswersEntity.swift index 8edb6a71..b26bf296 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Domain/Entity/CommonQuestAnswersEntity.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Domain/Entity/CommonQuestAnswersEntity.swift @@ -20,11 +20,14 @@ struct CommonQuestAnswersEntity { struct CommonQuestAnswerEntity { let isMyAnswer: Bool let answerID: Int + let writerID: Int let writer: String let profileIcon: String let writtenAt: String let content: String - let writerID: Int + let likeCount: Int + let commentCount: Int + let isLiked: Bool } extension CommonQuestAnswersEntity { @@ -35,11 +38,14 @@ extension CommonQuestAnswersEntity { CommonQuestAnswerEntity( isMyAnswer: false, answerID: $0, + writerID: $0, writer: "유저\($0)", profileIcon: profileIcons[$0 % 4], writtenAt: Date.now.toString(), content: "\($0)번째 테스트 답변", - writerID: 1 + likeCount: 1, + commentCount: 1, + isLiked: false ) } @@ -61,11 +67,14 @@ extension CommonQuestAnswerEntity { .init( isMyAnswer: false, answerID: 1, + writerID: 12, writer: "장원영", profileIcon: "SO_SO", writtenAt: "2025-10-12", content: "헤어진 지 벌써 일주일이 지났습니다. 처음에는 실감이 안 나서 눈물조차 나오지 않았어요. 그저 멍하니 천장만 바라보며 시간을 보냈습니다. 그런데 오늘 아침, 습관적으로 휴대폰을 확인하다가 더 이상 '굿모닝' 인사를 보낼 사람이 없다는 사실을 깨닫고 그제야 무너져 내렸습니다. 밥알이 모래알 같아서 잘 넘어가지도 않네요. 친구들은 시간이 약이라고, 더 좋은 사람 만날 거라고 위로하지만 지금 당장은 그 어떤 말도 귀에 들어오지 않습니다.", - writerID: 1 + likeCount: 3, + commentCount: 4, + isLiked: false ) } } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Domain/Entity/CommonQuestCommentEntity.swift b/ByeBoo-iOS/ByeBoo-iOS/Domain/Entity/CommonQuestCommentEntity.swift new file mode 100644 index 00000000..5b2f4794 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Domain/Entity/CommonQuestCommentEntity.swift @@ -0,0 +1,78 @@ +// +// CommonQuestCommentEntity.swift +// ByeBoo-iOS +// +// Created by 이나연 on 5/5/26. +// + +import Foundation + +struct CommonQuestCommentEntity: Hashable { + let isMyComment: Bool + let commentID: Int + let replyCount: Int? + let writerID: Int + let writer: String + let profileIcon: String + let writtenAt: String + let content: String +} + +extension CommonQuestCommentEntity { + static func toCommentListStub() -> [Self] { + return (1...5).map { + .init( + isMyComment: false, + commentID: $0, + replyCount: $0 - 1, + writerID: 2, + writer: "장원영", + profileIcon: "SADNESS", + writtenAt: "2026-02-19T02:09:43.735730", + content: "나도여ㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜ냐냐냐냐냐냐냐냐냐" + ) + } + } + + static func toCommentStub() -> Self { + .init( + isMyComment: false, + commentID: 1, + replyCount: 2, + writerID: 2, + writer: "가을이", + profileIcon: "SELF_UNDERSTANDING", + writtenAt: "2026-02-19T02:09:43.735730", + content: "나도여ㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜㅜ냐냐냐냐냐냐냐냐냐" + ) + } + + static func toReplyListStub() -> [Self] { + return (6...10).map { + .init( + isMyComment: false, + commentID: $0, + replyCount: nil, + writerID: 2, + writer: "안유진", + profileIcon: "SADNESS", + writtenAt: "2026-02-19T02:09:43.735730", + content: "헤어짐ㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠㅠ" + ) + } + } + + + static func toReplyStub() -> Self { + .init( + isMyComment: false, + commentID: 1, + replyCount: nil, + writerID: 2, + writer: "가을이", + profileIcon: "SELF_UNDERSTANDING", + writtenAt: "2026-02-19T02:09:43.735730", + content: "헤어짐" + ) + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Domain/Entity/CommonQuestDetailEntity.swift b/ByeBoo-iOS/ByeBoo-iOS/Domain/Entity/CommonQuestDetailEntity.swift new file mode 100644 index 00000000..07ec2e18 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Domain/Entity/CommonQuestDetailEntity.swift @@ -0,0 +1,26 @@ +// +// CommonQuestDetailEntity.swift +// ByeBoo-iOS +// +// Created by 이나연 on 6/9/26. +// + +import Foundation + +struct CommonQuestDetailEntity { + let question: String + let answer: CommonQuestAnswerDetailEntity + let comments: [CommonQuestCommentEntity] +} + +struct CommonQuestAnswerDetailEntity { + let isMyAnswer: Bool + let content: String + let writerID: Int + let writer: String + let profileIcon: String + let likeCount: Int + let commentCount: Int + let isLiked: Bool + let writtenAt: String +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Domain/Entity/CommonQuestLikeEntity.swift b/ByeBoo-iOS/ByeBoo-iOS/Domain/Entity/CommonQuestLikeEntity.swift new file mode 100644 index 00000000..9f30a2cf --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Domain/Entity/CommonQuestLikeEntity.swift @@ -0,0 +1,13 @@ +// +// CommonQuestLikeEntity.swift +// ByeBoo-iOS +// +// Created by 이나연 on 6/13/26. +// + +import Foundation + +struct CommonQuestLikeEntity { + let isLiked: Bool + let likeCount: Int +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Domain/Entity/CommonQuestMyAnswersEntity.swift b/ByeBoo-iOS/ByeBoo-iOS/Domain/Entity/CommonQuestMyAnswersEntity.swift index bc49f98d..bd4d0b9f 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Domain/Entity/CommonQuestMyAnswersEntity.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Domain/Entity/CommonQuestMyAnswersEntity.swift @@ -18,6 +18,9 @@ struct CommonQuestMyAnswerEntity { let writtenAt: String let content: String let question: String + let isLiked: Bool + let likeCount: Int + let commentCount: Int } extension CommonQuestMyAnswersEntity { @@ -27,7 +30,10 @@ extension CommonQuestMyAnswersEntity { answerID: $0, writtenAt: Date.now.toString(), content: "\($0)번째 테스트 답변", - question: "\($0)번째 질문" + question: "\($0)번째 질문", + isLiked: false, + likeCount: 2, + commentCount: 3 ) } @@ -47,10 +53,22 @@ extension CommonQuestMyAnswerEntity { static func stubs() -> [Self] { [ - .init(answerID: 1, writtenAt: "2025-01-11", content: "content", question: "question"), - .init(answerID: 2, writtenAt: "2025-01-12", content: "content", question: "question"), - .init(answerID: 3, writtenAt: "2025-01-13", content: "content", question: "question"), - .init(answerID: 4, writtenAt: "2025-01-14", content: "content", question: "question") + .init( + answerID: 1, writtenAt: "2025-01-11", content: "content", + question: "question", isLiked: false, likeCount: 1, commentCount: 2 + ), + .init( + answerID: 2, writtenAt: "2025-01-12", content: "content", + question: "question", isLiked: false, likeCount: 1, commentCount: 2 + ), + .init( + answerID: 3, writtenAt: "2025-01-13", content: "content", + question: "question", isLiked: false, likeCount: 1, commentCount: 2 + ), + .init( + answerID: 4, writtenAt: "2025-01-14", content: "content", + question: "question", isLiked: false, likeCount: 1, commentCount: 2 + ) ] } } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Domain/Entity/CommonQuestReplyListEntity.swift b/ByeBoo-iOS/ByeBoo-iOS/Domain/Entity/CommonQuestReplyListEntity.swift new file mode 100644 index 00000000..d0413ab1 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Domain/Entity/CommonQuestReplyListEntity.swift @@ -0,0 +1,13 @@ +// +// CommonQuestReplyListEntity.swift +// ByeBoo-iOS +// +// Created by 이나연 on 6/15/26. +// + +import Foundation + +struct CommonQuestReplyListEntity { + let totalCount: Int + let replies: [CommonQuestCommentEntity] +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Domain/Entity/Enum/CommonQuestTargetType.swift b/ByeBoo-iOS/ByeBoo-iOS/Domain/Entity/Enum/CommonQuestTargetType.swift new file mode 100644 index 00000000..240163a5 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Domain/Entity/Enum/CommonQuestTargetType.swift @@ -0,0 +1,13 @@ +// +// CommonQuestTargetType.swift +// ByeBoo-iOS +// +// Created by 이나연 on 6/16/26. +// + +import Foundation + +enum CommonQuestTargetType: String { + case comment = "COMMENT" + case commonQuest = "COMMON_QUEST" +} 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..47a4a9d5 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Domain/Entity/NotificationListEntity.swift @@ -0,0 +1,97 @@ +// +// 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: "3번째 퀘스트가 오픈됐어요, 시작해볼까요?", + isRead: false, + createdAt: "2026-06-07T11:29:00.735730", + landingURL: "myapp://quest/3" + ), + NotificationEntity( + notificationID: 8, + notificationType: .comment, + title: "공통여정에 답변이 달렸어요 💬", + content: "내가 작성한 글에 보리보리쌀님이 답변을 남겼어요", + isRead: true, + createdAt: "2026-06-07T08:35:43.735730", + landingURL: "myapp://common-quests/486" + ), + NotificationEntity( + notificationID: 7, + notificationType: .like, + title: "공통여정에 답변에 공감이 달렸어요 ❤️", + content: "내가 작성한 글에 보리보리쌀님이 공감을 남겼어요", + isRead: true, + createdAt: "2026-06-06T15:35:43.735730", + landingURL: "myapp://common-quests/486" + ), + NotificationEntity( + notificationID: 6, + notificationType: .questOpen, + title: "오늘의 퀘스트 오픈 🌱", + content: "3번째 퀘스트가 오픈됐어요, 시작해볼까요?", + isRead: false, + createdAt: "2026-02-19T02:09:43.735730", + landingURL: "myapp://quest/3" + ), + NotificationEntity( + notificationID: 5, + notificationType: .comment, + title: "공통여정에 답변이 달렸어요 💬", + content: "내가 작성한 글에 보리보리쌀님이 답변을 남겼어요", + isRead: true, + createdAt: "2026-02-19T02:09:43.735730", + landingURL: "myapp://common-quests/486" + ), + NotificationEntity( + notificationID: 4, + notificationType: .like, + title: "공통여정에 답변에 공감이 달렸어요 ❤️", + content: "내가 작성한 글에 보리보리쌀님이 공감을 남겼어요", + isRead: true, + createdAt: "2026-02-19T02:09:43.735730", + landingURL: "myapp://common-quests/486" + ) + ] + ) + } +} + +extension NotificationEntity { + func markAsRead() -> Self { + .init( + notificationID: notificationID, + notificationType: notificationType, + title: title, + content: content, + isRead: true, + createdAt: createdAt, + landingURL: landingURL + ) + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Domain/Interface/CommentInterface.swift b/ByeBoo-iOS/ByeBoo-iOS/Domain/Interface/CommentInterface.swift new file mode 100644 index 00000000..2537b683 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Domain/Interface/CommentInterface.swift @@ -0,0 +1,16 @@ +// +// CommentInterface.swift +// ByeBoo-iOS +// +// Created by 이나연 on 6/14/26. +// + +import Foundation + +protocol CommentInterface { + func postComment(content: String, answerID: Int) async throws + func postReply(content: String, commentID: Int) async throws + func fetchReplies(commentID: Int) async throws -> CommonQuestReplyListEntity + func patchComment(content: String, commentID: Int) async throws + func deleteComment(commentID: Int) async throws +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Domain/Interface/CommonQuestInterface.swift b/ByeBoo-iOS/ByeBoo-iOS/Domain/Interface/CommonQuestInterface.swift index 599821de..478df520 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Domain/Interface/CommonQuestInterface.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Domain/Interface/CommonQuestInterface.swift @@ -12,4 +12,6 @@ protocol CommonQuestInterface { func fetchCommonQuest(date: String, cursor: Int?) async throws -> CommonQuestAnswersEntity func updateCommonQuest(answerID: Int, answer: String) async throws func deleteCommonQuest(answerID: Int) async throws + func fetchCommonQuestDetail(answerID: Int) async throws -> CommonQuestDetailEntity + func postCommonQuestLikes(answerID: Int) async throws -> CommonQuestLikeEntity } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Domain/Interface/ForeceUpdateInterface.swift b/ByeBoo-iOS/ByeBoo-iOS/Domain/Interface/ForeceUpdateInterface.swift new file mode 100644 index 00000000..9e0cc6ac --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Domain/Interface/ForeceUpdateInterface.swift @@ -0,0 +1,10 @@ +// +// ForeceUpdateInterface.swift +// ByeBoo-iOS +// +// Created by 이나연 on 7/5/26. +// + +protocol ForceUpdateInterface { + func checkForUpdate() async -> Bool +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Domain/Interface/NotificationInterface.swift b/ByeBoo-iOS/ByeBoo-iOS/Domain/Interface/NotificationInterface.swift index 05cc0709..c835dd3d 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Domain/Interface/NotificationInterface.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Domain/Interface/NotificationInterface.swift @@ -2,13 +2,12 @@ // 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 + func readNotification(for notificationID: Int) async throws + func readAllNotifications() async throws } 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/Interface/QuestsInterface.swift b/ByeBoo-iOS/ByeBoo-iOS/Domain/Interface/QuestsInterface.swift index beb9e86e..2c0a3d71 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Domain/Interface/QuestsInterface.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Domain/Interface/QuestsInterface.swift @@ -31,5 +31,6 @@ protocol QuestsInterface { imageKey: String, isImageChanged: Bool) async throws func fetchCommoncQuest(date: String) async throws -> CommonQuestAnswersEntity - func fetchAIAnswer(questID: Int, isAnswerExists: Bool) async throws -> AIAnswerEntity + func fetchAIAnswer(questID: Int) async throws -> AIAnswerEntity + func createAIAnswer(questID: Int) async throws -> AIAnswerEntity } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Domain/Interface/ReportsInterface.swift b/ByeBoo-iOS/ByeBoo-iOS/Domain/Interface/ReportsInterface.swift index c7b35587..64677646 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Domain/Interface/ReportsInterface.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Domain/Interface/ReportsInterface.swift @@ -6,5 +6,5 @@ // protocol ReportsInterface { - func reportCommonQuest(answerID: Int) async throws + func reportCommonQuest(targetID: Int, targetType: CommonQuestTargetType) async throws } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Domain/UseCase/CheckForceUpdateUseCase.swift b/ByeBoo-iOS/ByeBoo-iOS/Domain/UseCase/CheckForceUpdateUseCase.swift new file mode 100644 index 00000000..e9dc4e2b --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Domain/UseCase/CheckForceUpdateUseCase.swift @@ -0,0 +1,25 @@ +// +// CheckForceUpdateUseCase.swift +// ByeBoo-iOS +// +// Created by 이나연 on 7/4/26. +// + +import Foundation + +protocol CheckForceUpdateUseCase { + func execute() async -> Bool +} + +struct DefaultCheckForceUpdateUseCase: CheckForceUpdateUseCase { + + private let repository: ForceUpdateInterface + + init(repository: ForceUpdateInterface) { + self.repository = repository + } + + func execute() async -> Bool { + return await repository.checkForUpdate() + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Domain/UseCase/DeleteCommentReplyUseCase.swift b/ByeBoo-iOS/ByeBoo-iOS/Domain/UseCase/DeleteCommentReplyUseCase.swift new file mode 100644 index 00000000..b9023553 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Domain/UseCase/DeleteCommentReplyUseCase.swift @@ -0,0 +1,25 @@ +// +// DeleteCommentReplyUseCase.swift +// ByeBoo-iOS +// +// Created by 이나연 on 6/16/26. +// + +import Foundation + +protocol DeleteCommentReplyUseCase { + func execute(commentID: Int) async throws +} + +struct DefaultDeleteCommentReplyUseCase: DeleteCommentReplyUseCase { + + private let repository: CommentInterface + + init(repository: CommentInterface) { + self.repository = repository + } + + func execute(commentID: Int) async throws { + try await repository.deleteComment(commentID: commentID) + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Domain/UseCase/EditCommentReplyUseCase.swift b/ByeBoo-iOS/ByeBoo-iOS/Domain/UseCase/EditCommentReplyUseCase.swift new file mode 100644 index 00000000..23e58e7c --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Domain/UseCase/EditCommentReplyUseCase.swift @@ -0,0 +1,25 @@ +// +// EditCommentReplyUseCase.swift +// ByeBoo-iOS +// +// Created by 이나연 on 6/16/26. +// + +import Foundation + +protocol EditCommentReplyUseCase { + func execute(content: String, targetID: Int) async throws +} + +struct DefaultEditCommentReplyUseCase: EditCommentReplyUseCase { + + private let repository: CommentInterface + + init(repository: CommentInterface) { + self.repository = repository + } + + func execute(content: String, targetID: Int) async throws { + try await repository.patchComment(content: content, commentID: targetID) + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Domain/UseCase/FetchAIAnswerUseCase.swift b/ByeBoo-iOS/ByeBoo-iOS/Domain/UseCase/FetchAIAnswerUseCase.swift index 03745916..fcea3f1b 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Domain/UseCase/FetchAIAnswerUseCase.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Domain/UseCase/FetchAIAnswerUseCase.swift @@ -17,6 +17,10 @@ struct DefaultFetchAIAnswerUseCase: FetchAIAnswerUseCase { } func execute(questID: Int, isAnswerExists: Bool) async throws -> AIAnswerEntity { - try await repository.fetchAIAnswer(questID: questID, isAnswerExists: isAnswerExists) + if isAnswerExists { + return try await repository.fetchAIAnswer(questID: questID) + } else { + return try await repository.createAIAnswer(questID: questID) + } } } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Domain/UseCase/FetchCommonQuestDetailUseCase.swift b/ByeBoo-iOS/ByeBoo-iOS/Domain/UseCase/FetchCommonQuestDetailUseCase.swift new file mode 100644 index 00000000..d79f3958 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Domain/UseCase/FetchCommonQuestDetailUseCase.swift @@ -0,0 +1,25 @@ +// +// FetchCommonQuestDetailUseCase.swift +// ByeBoo-iOS +// +// Created by 이나연 on 5/26/26. +// + +import Foundation + +protocol FetchCommonQuestDetailUseCase { + func execute(answerID: Int) async throws -> CommonQuestDetailEntity +} + +struct DefaultFetchCommonQuestDetailUseCase: FetchCommonQuestDetailUseCase { + + private let repository: CommonQuestInterface + + init(repository: CommonQuestInterface) { + self.repository = repository + } + + func execute(answerID: Int) async throws -> CommonQuestDetailEntity { + return try await repository.fetchCommonQuestDetail(answerID: answerID) + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Domain/UseCase/FetchCommonQuestRepliesUseCase.swift b/ByeBoo-iOS/ByeBoo-iOS/Domain/UseCase/FetchCommonQuestRepliesUseCase.swift new file mode 100644 index 00000000..c89dbc67 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Domain/UseCase/FetchCommonQuestRepliesUseCase.swift @@ -0,0 +1,24 @@ +// +// FetchCommonQuestRepliesUseCase.swift +// ByeBoo-iOS +// +// Created by 이나연 on 6/15/26. +// + +import Foundation + +protocol FetchCommonQuestRepliesUseCase { + func execute(commentID: Int) async throws -> CommonQuestReplyListEntity +} + +struct DefaultFetchCommonQuestRepliesUseCase: FetchCommonQuestRepliesUseCase { + private let repository: CommentInterface + + init(repository: CommentInterface) { + self.repository = repository + } + + func execute(commentID: Int) async throws -> CommonQuestReplyListEntity { + return try await repository.fetchReplies(commentID: commentID) + } +} 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 .. CommonQuestLikeEntity +} + +struct DefaultPostCommonQuestLikeUseCase: PostCommonQuestLikeUseCase { + private let repository: CommonQuestInterface + + init(repository: CommonQuestInterface) { + self.repository = repository + } + + func execute(answerID: Int) async throws -> CommonQuestLikeEntity { + return try await repository.postCommonQuestLikes(answerID: answerID) + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Domain/UseCase/PostCommonQuestReplyUseCase.swift b/ByeBoo-iOS/ByeBoo-iOS/Domain/UseCase/PostCommonQuestReplyUseCase.swift new file mode 100644 index 00000000..d52440b9 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Domain/UseCase/PostCommonQuestReplyUseCase.swift @@ -0,0 +1,24 @@ +// +// PostCommonQuestReplyUseCase.swift +// ByeBoo-iOS +// +// Created by 이나연 on 6/14/26. +// + +import Foundation + +protocol PostCommonQuestReplyUseCase { + func execute(content: String, commentID: Int) async throws +} + +struct DefaultPostCommonQuestReplyUseCase: PostCommonQuestReplyUseCase { + private let repository: CommentInterface + + init(repository: CommentInterface) { + self.repository = repository + } + + func execute(content: String, commentID: Int) async throws { + try await repository.postReply(content: content, commentID: commentID) + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Domain/UseCase/ReadAllNotificationsUseCase.swift b/ByeBoo-iOS/ByeBoo-iOS/Domain/UseCase/ReadAllNotificationsUseCase.swift new file mode 100644 index 00000000..d7657f98 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Domain/UseCase/ReadAllNotificationsUseCase.swift @@ -0,0 +1,23 @@ +// +// ReadAllNotificationsUseCase.swift +// ByeBoo-iOS +// +// Created by 더스틴 on 6/23/26. +// + +protocol ReadAllNotificationsUseCase { + func execute() async throws +} + +struct DefaultReadAllNotificationsUseCase: ReadAllNotificationsUseCase { + + private let repository: NotificationInterface + + init(repository: NotificationInterface) { + self.repository = repository + } + + func execute() async throws { + try await repository.readAllNotifications() + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Domain/UseCase/ReadNotificationUseCase.swift b/ByeBoo-iOS/ByeBoo-iOS/Domain/UseCase/ReadNotificationUseCase.swift new file mode 100644 index 00000000..f09c58bc --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Domain/UseCase/ReadNotificationUseCase.swift @@ -0,0 +1,23 @@ +// +// ReadNotificationUseCase.swift +// ByeBoo-iOS +// +// Created by 더스틴 on 6/8/26. +// + +protocol ReadNotificationUseCase { + func execute(for notificationID: Int) async throws +} + +struct DefaultReadNotificationUseCase: ReadNotificationUseCase { + + private let repository: NotificationInterface + + init(repository: NotificationInterface) { + self.repository = repository + } + + func execute(for notificationID: Int) async throws { + try await repository.readNotification(for: notificationID) + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Domain/UseCase/ReportsCommonQuestAnswerUseCase.swift b/ByeBoo-iOS/ByeBoo-iOS/Domain/UseCase/ReportsCommonQuestAnswerUseCase.swift index a2266e96..a37329fd 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Domain/UseCase/ReportsCommonQuestAnswerUseCase.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Domain/UseCase/ReportsCommonQuestAnswerUseCase.swift @@ -8,7 +8,7 @@ import Foundation protocol ReportsCommonQuestAnswerUseCase { - func execute(answerID: Int) async throws + func execute(targetID: Int, targetType: CommonQuestTargetType) async throws } struct DefaultReportsCommonQuestAnswerUseCase: ReportsCommonQuestAnswerUseCase { @@ -21,7 +21,7 @@ struct DefaultReportsCommonQuestAnswerUseCase: ReportsCommonQuestAnswerUseCase { self.repository = repository } - func execute(answerID: Int) async throws { - let _ = try await repository.reportCommonQuest(answerID: answerID) + func execute(targetID: Int, targetType: CommonQuestTargetType) async throws { + let _ = try await repository.reportCommonQuest(targetID: targetID, targetType: targetType) } } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Common/BottomSheet/CommonQuest/CommonQuestBottomSheetViewController.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Common/BottomSheet/CommonQuest/CommonQuestBottomSheetViewController.swift index e3191e81..e89e418a 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Common/BottomSheet/CommonQuest/CommonQuestBottomSheetViewController.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Common/BottomSheet/CommonQuest/CommonQuestBottomSheetViewController.swift @@ -8,13 +8,14 @@ import Combine import UIKit -protocol CommonQuestBottomSheetDelegate: AnyObject { - func didTapEdit( +protocol EditCommonQuestProtocol: AnyObject { + func didTapCommonQuestEdit( answerID: Int, answer: String, question: String, writtenAt: String ) + func didTapCommentEdit(commentID: Int, content: String) } protocol BlockReportProtocol: AnyObject { @@ -22,7 +23,7 @@ protocol BlockReportProtocol: AnyObject { } protocol DeleteCommonQuestDelegate: AnyObject { - func completeDeleteCommonQuest() + func completeDeleteCommonQuest(deletedID: Int) } final class CommonQuestBottomSheetViewController: BaseViewController { @@ -30,12 +31,13 @@ final class CommonQuestBottomSheetViewController: BaseViewController { private let yesAnswer = "예" private let noAnswer = "아니오" - private var rootView = CommonQuestBottomSheetView(sheetType: .other) + private var rootView = CommonQuestBottomSheetView(sheetType: .otherAnswer) private let viewModel: CommonQuestBottomSheetViewModel private var writerID: Int = 0 private var cancellables = Set() - private(set) var answerID: Int? + private(set) var targetID: Int? private(set) var answer: String? + private(set) var content: String? private(set) var question: String? private(set) var writtenAt: String? @@ -43,7 +45,7 @@ final class CommonQuestBottomSheetViewController: BaseViewController { var action: CommonQuestArchiveType.Action? weak var blockDelegate: BlockReportProtocol? - weak var bottomDelegate: CommonQuestBottomSheetDelegate? + weak var editDelegate: EditCommonQuestProtocol? weak var deleteDelegate: DeleteCommonQuestDelegate? init(viewModel: CommonQuestBottomSheetViewModel) { @@ -63,7 +65,7 @@ final class CommonQuestBottomSheetViewController: BaseViewController { super.viewDidLoad() bind() } - + override func setAddTarget() { rootView.dismissButton.addTarget(self, action: #selector(dismissButtonDidTap), for: .touchUpInside) rootView.itemList.enumerated().forEach { index, item in @@ -78,26 +80,23 @@ final class CommonQuestBottomSheetViewController: BaseViewController { extension CommonQuestBottomSheetViewController { func configure( - sheeetType: CommonQuestArchiveType, - answerID: Int? = nil, + sheetType: CommonQuestArchiveType, + targetID: Int, + writerID: Int = 0, + content: String? = nil, answer: String? = nil, question: String? = nil, writtenAt: String? = nil ) { - self.sheetType = sheeetType - self.answerID = answerID + self.sheetType = sheetType + self.targetID = targetID + self.writerID = writerID + self.content = content self.answer = answer self.question = question self.writtenAt = writtenAt - } - - - func configure(sheeetType: CommonQuestArchiveType, writerID: Int) { - self.sheetType = sheeetType - self.writerID = writerID - if let sheetType { - rootView = CommonQuestBottomSheetView(sheetType: sheetType) - } + + rootView = CommonQuestBottomSheetView(sheetType: sheetType) } @objc @@ -111,27 +110,46 @@ extension CommonQuestBottomSheetViewController { action = sheetType.items[index].action switch action { - case .edit: - guard let answerID, let answer, let question, let writtenAt - else { - return - } + case .questEdit: + guard let targetID, let answer, let question, let writtenAt else { return } dismiss(animated: false) { [weak self] in - self?.bottomDelegate?.didTapEdit( - answerID: answerID, + self?.editDelegate?.didTapCommonQuestEdit( + answerID: targetID, answer: answer, question: question, writtenAt: writtenAt ) } + case .commentEdit: + guard let targetID, let content else { return } + dismiss(animated: false) { [weak self] in + self?.editDelegate?.didTapCommentEdit(commentID: targetID, content: content) + } case .delete: - guard let answerID else { return } - presentDeleteQuestModal(answerID: answerID) + var targetType: CommonQuestTargetType? = nil + switch sheetType { + case .myAnswer, .otherAnswer: + targetType = .commonQuest + case .myComment, .otherComment: + targetType = .comment + } + + guard let targetID, let targetType else { return } + presentDeleteQuestModal(answerID: targetID, targetType: targetType) case .block: viewModel.action(.block(userID: writerID)) case .report: - viewModel.action(.report(answerID: answerID ?? 0)) + var targetType: CommonQuestTargetType? = nil + switch sheetType { + case .myAnswer, .otherAnswer: + targetType = .commonQuest + case .myComment, .otherComment: + targetType = .comment + } + + guard let targetID, let targetType else { return } + viewModel.action(.report(targetID: targetID, targetType: targetType)) default: return } @@ -142,9 +160,9 @@ extension CommonQuestBottomSheetViewController { presentingViewController?.dismiss(animated: true) } - private func presentDeleteQuestModal(answerID: Int) { + private func presentDeleteQuestModal(answerID: Int, targetType: CommonQuestTargetType) { let modalView = createModalView() - let action: () -> Void = { self.viewModel.action(.delete(answerID: answerID)) } + let action: () -> Void = { self.viewModel.action(.delete(targetID: answerID, targetType: targetType)) } ModalBuilder( modalView: modalView, @@ -202,11 +220,11 @@ extension CommonQuestBottomSheetViewController { switch result { case .success(): ByeBooLogger.debug("삭제 성공") - guard let self else { return } - - self.dismiss(animated: false) { [weak self] in - self?.deleteDelegate?.completeDeleteCommonQuest() + let deleteDelegate = self.deleteDelegate + self.dismiss(animated: false) { + guard let deletedID = self.targetID else { return } + deleteDelegate?.completeDeleteCommonQuest(deletedID: deletedID) } case .failure(let error): ByeBooLogger.debug(error) diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Common/BottomSheet/CommonQuest/CommonQuestBottomSheetViewModel.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Common/BottomSheet/CommonQuest/CommonQuestBottomSheetViewModel.swift index 9083ca6a..8a6d5086 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Common/BottomSheet/CommonQuest/CommonQuestBottomSheetViewModel.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Common/BottomSheet/CommonQuest/CommonQuestBottomSheetViewModel.swift @@ -25,24 +25,27 @@ final class CommonQuestBottomSheetViewModel { private let blockUserUseCase: BlockUserUseCase private let reportCommonQuestUseCase: ReportsCommonQuestAnswerUseCase private let deleteCommonQuestUseCase: DeleteCommonQuestUseCase + private let deleteCommentReplyUseCase: DeleteCommentReplyUseCase private var cancellables = Set() init( blockUserUseCase: BlockUserUseCase, reportCommonQuestUseCase: ReportsCommonQuestAnswerUseCase, - deleteCommonQuestUseCase: DeleteCommonQuestUseCase + deleteCommonQuestUseCase: DeleteCommonQuestUseCase, + deleteCommentReplyUseCase: DeleteCommentReplyUseCase ) { self.blockUserUseCase = blockUserUseCase self.reportCommonQuestUseCase = reportCommonQuestUseCase self.deleteCommonQuestUseCase = deleteCommonQuestUseCase + self.deleteCommentReplyUseCase = deleteCommentReplyUseCase } } extension CommonQuestBottomSheetViewModel: ViewModelType { enum Input { case block(userID: Int) - case report(answerID: Int) - case delete(answerID: Int) + case report(targetID: Int, targetType: CommonQuestTargetType) + case delete(targetID: Int, targetType: CommonQuestTargetType) } struct Output { @@ -55,10 +58,14 @@ extension CommonQuestBottomSheetViewModel: ViewModelType { switch trigger { case .block(let userID): blockUser(userID: userID) - case .report(let answerID): - reportCommonQuest(answerID: answerID) - case .delete(let answerID): - deleteCommonQuest(answerID: answerID) + case .report(let targetID, let targetType): + reportCommonQuest(targetID: targetID, targetType: targetType) + case .delete(let answerID, let targetType): + if targetType == .comment { + deleteCommentReply(commentID: answerID) + } else { + deleteCommonQuest(answerID: answerID) + } } } } @@ -69,19 +76,19 @@ extension CommonQuestBottomSheetViewModel { do { try await blockUserUseCase.execute(userID: userID) blockUserSubject.send(.success(())) - } catch { - sendError(error: error) + } catch(let error as ByeBooError) { + blockUserSubject.send(.failure(error)) } } } - private func reportCommonQuest(answerID: Int) { + private func reportCommonQuest(targetID: Int, targetType: CommonQuestTargetType) { Task { do { - try await reportCommonQuestUseCase.execute(answerID: answerID) + try await reportCommonQuestUseCase.execute(targetID: targetID, targetType: targetType) reportQuestSubject.send(.success(())) - } catch { - sendError(error: error) + } catch(let error as ByeBooError) { + reportQuestSubject.send(.failure(error)) } } } @@ -91,17 +98,20 @@ extension CommonQuestBottomSheetViewModel { do { try await deleteCommonQuestUseCase.execute(answerID: answerID) deleteQuestSubject.send(.success(())) - } catch { - sendError(error: error) + } catch(let error as ByeBooError){ + deleteQuestSubject.send(.failure(error)) } } } - private func sendError(error: Error) { - guard let error = error as? ByeBooError else { - return + private func deleteCommentReply(commentID: Int) { + Task { + do { + try await deleteCommentReplyUseCase.execute(commentID: commentID) + deleteQuestSubject.send(.success(())) + } catch(let error as ByeBooError){ + deleteQuestSubject.send(.failure(error)) + } } - ByeBooLogger.error(error as ByeBooError) - reportQuestSubject.send(.failure(error)) } } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Common/Modal/ForceUpdateModalView.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Common/Modal/ForceUpdateModalView.swift new file mode 100644 index 00000000..64d5f6ea --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Common/Modal/ForceUpdateModalView.swift @@ -0,0 +1,60 @@ +// +// ForceUpdateModalView.swift +// ByeBoo-iOS +// +// Created by 이나연 on 7/4/26. +// + +import UIKit + +import SnapKit +import Then + +final class ForceUpdateModalView: BaseView, ModalProtocol { + let actionButton: ByeBooButton = ByeBooButton(titleText: "업데이트 하러 가기", type: .enabled) + let dismissButton: ByeBooButton? = nil + let modalType: ConfirmModalType? = nil + + private let titleLabel = UILabel() + private let descriptionLabel = UILabel() + + override func setUI() { + addSubviews(titleLabel, descriptionLabel, actionButton) + } + + override func setStyle() { + backgroundColor = .grayscale900 + layer.cornerRadius = 12 + + titleLabel.applyByeBooFont( + style: .sub3M18, + text: "새로운 업데이트가 있어요", + color: .grayscale50 + ) + + descriptionLabel.applyByeBooFont( + style: .body3R16, + text: "더 나은 바이부를 만나보세요", + color: .grayscale400 + ) + } + + override func setLayout() { + titleLabel.snp.makeConstraints { + $0.top.equalToSuperview().inset(24.adjustedH) + $0.centerX.equalToSuperview() + } + descriptionLabel.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(16.adjustedH) + $0.centerX.equalToSuperview() + } + actionButton.snp.makeConstraints { + $0.top.equalTo(descriptionLabel.snp.bottom).offset(16.adjustedH) + $0.centerX.equalToSuperview() + $0.horizontalEdges.equalToSuperview().inset(24.adjustedW) + $0.bottom.equalToSuperview().inset(24.adjustedH) + $0.height.equalTo(53.adjustedH) + } + } +} + diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/DeepLink/Coordinator/CommonQuestAnswerDeepLinkCoordinator.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/DeepLink/Coordinator/CommonQuestAnswerDeepLinkCoordinator.swift new file mode 100644 index 00000000..11fe14fd --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/DeepLink/Coordinator/CommonQuestAnswerDeepLinkCoordinator.swift @@ -0,0 +1,46 @@ +// +// CommonQuestAnswerDeepLinkCoordinator.swift +// ByeBoo-iOS +// +// Created by 더스틴 on 6/18/26. +// + +import UIKit + +struct CommonQuestAnswerDeepLinkCoordinator: QuestDeepLinkCoordinator { + + let answerID: Int + + init(answerID: Int) { + self.answerID = answerID + } + + func navigate(from window: UIWindow) { + let tabBarController = ViewControllerFactory.shared.makeByeBooTabBar() + tabBarController.selectedIndex = questViewIndex + + ViewControllerUtils.setRootViewController( + window: window, + viewController: tabBarController, + withAnimation: false + ) + + guard let navigationController = findNavigationController(from: tabBarController, index: questViewIndex), + let _: CommonQuestViewController = selectedQuestTab(from: navigationController) + else { + return + } + + pushToHistory(from: navigationController) + } +} + +private extension CommonQuestAnswerDeepLinkCoordinator { + + func pushToHistory(from navigationController: UINavigationController) { + let historyVC = ViewControllerFactory.shared.makeCommonQuestHistoryViewController() + historyVC.configure(answerID: answerID) + + navigationController.pushViewController(historyVC, animated: false) + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/DeepLink/Coordinator/MyJourneyDeepLinkCoordinator.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/DeepLink/Coordinator/MyJourneyDeepLinkCoordinator.swift new file mode 100644 index 00000000..afafe4b5 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/DeepLink/Coordinator/MyJourneyDeepLinkCoordinator.swift @@ -0,0 +1,36 @@ +// +// MyJourneyDeepLinkCoordinator.swift +// ByeBoo-iOS +// +// Created by 더스틴 on 6/18/26. +// + +import UIKit + +struct MyJourneyDeepLinkCoordinator: QuestDeepLinkCoordinator { + + let questNumber: Int + + init(questNumber: Int) { + self.questNumber = questNumber + } + + func navigate(from window: UIWindow) { + let tabBarController = ViewControllerFactory.shared.makeByeBooTabBar() + tabBarController.selectedIndex = questViewIndex + + ViewControllerUtils.setRootViewController( + window: window, + viewController: tabBarController, + withAnimation: false + ) + + guard let navigationController = findNavigationController(from: tabBarController, index: questViewIndex), + let questCheckVC: QuestCheckViewController = selectedQuestTab(from: navigationController) + else { + return + } + + questCheckVC.setPendingQuestNumber(questNumber) + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/DeepLink/Coordinator/Protocol/DeepLinkCoordinator.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/DeepLink/Coordinator/Protocol/DeepLinkCoordinator.swift new file mode 100644 index 00000000..28b21125 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/DeepLink/Coordinator/Protocol/DeepLinkCoordinator.swift @@ -0,0 +1,25 @@ +// +// DeepLinkCoordinator.swift +// ByeBoo-iOS +// +// Created by 더스틴 on 6/17/26. +// + +import UIKit + +protocol DeepLinkCoordinator { + func navigate(from window: UIWindow) + func findNavigationController(from tabBarController: ByeBooTabBar, index: Int) -> UINavigationController? +} + +extension DeepLinkCoordinator { + func findNavigationController(from tabBarController: ByeBooTabBar, index: Int) -> UINavigationController? { + guard let controllers = tabBarController.viewControllers, + controllers.indices.contains(index) + else { + return nil + } + + return tabBarController.viewControllers?[index] as? UINavigationController + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/DeepLink/Coordinator/Protocol/QuestDeepLinkCoordinator.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/DeepLink/Coordinator/Protocol/QuestDeepLinkCoordinator.swift new file mode 100644 index 00000000..d26d9a8c --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/DeepLink/Coordinator/Protocol/QuestDeepLinkCoordinator.swift @@ -0,0 +1,47 @@ +// +// QuestDeepLinkCoordinator.swift +// ByeBoo-iOS +// +// Created by 더스틴 on 6/20/26. +// + +import UIKit + +protocol QuestDeepLinkCoordinator: DeepLinkCoordinator { + + typealias QuestHostVC = ParentQuestViewController + + var questViewIndex: Int { get } + func selectedQuestTab(from navigationController: UINavigationController) -> T? +} + +extension QuestDeepLinkCoordinator { + + var questViewIndex: Int { 1 } + + func selectedQuestTab( + from navigationController: UINavigationController + ) -> T? { + guard let questHostVC = navigationController.viewControllers.first as? QuestHostVC, + let targetVC: T = questHostVC.controllers.first(where: { $0 is T }) as? T + else { + return nil + } + + selectQuestTab(for: questHostVC, target: targetVC) + + return targetVC + } +} + +private extension QuestDeepLinkCoordinator { + + func selectQuestTab( + for questHostVC: QuestHostVC, + target targetVC: T + ) { + if let targetIndex = questHostVC.controllers.firstIndex(of: targetVC as! UIViewController) { + questHostVC.selectTab(index: targetIndex) + } + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/DeepLink/DeepLinkHost.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/DeepLink/DeepLinkHost.swift new file mode 100644 index 00000000..92aedbac --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/DeepLink/DeepLinkHost.swift @@ -0,0 +1,20 @@ +// +// DeepLinkHost.swift +// ByeBoo-iOS +// +// Created by 더스틴 on 6/17/26. +// + +enum DeepLinkHost: String { + case quest = "quest" + case commonQuest = "common-quests" + + func destination(id: Int) -> DeepLinkCoordinator { + switch self { + case .quest: + MyJourneyDeepLinkCoordinator(questNumber: id) + case .commonQuest: + CommonQuestAnswerDeepLinkCoordinator(answerID: id) + } + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/DeepLink/DeepLinkParser.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/DeepLink/DeepLinkParser.swift new file mode 100644 index 00000000..2f6bd16c --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/DeepLink/DeepLinkParser.swift @@ -0,0 +1,59 @@ +// +// DeepLinkParser.swift +// ByeBoo-iOS +// +// Created by 더스틴 on 6/11/26. +// + +import Foundation + +enum DeepLinkParser { + + private static let scheme = "myapp" + private static let idIndex = 1 + + static func parse(from urlString: String) -> DeepLinkCoordinator? { + guard let url = validateURL(from: urlString), + let host = validateHost(from: url), + let id = extractID(from: url) + else { + return nil + } + + return host.destination(id: id) + } +} + +private extension DeepLinkParser { + + static func validateURL(from urlString: String) -> URL? { + guard let url = URL(string: urlString), + url.scheme == scheme + else { + return nil + } + + return url + } + + static func validateHost(from url: URL) -> DeepLinkHost? { + guard let hostString = url.host, + let host = DeepLinkHost(rawValue: hostString) + else { + return nil + } + + return host + } + + static func extractID(from url: URL) -> Int? { + let pathComponents = url.pathComponents + guard pathComponents.count == 2, + let id = Int(pathComponents[idIndex]) + else { + return nil + } + + return id + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Enum/AppConstants.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Enum/AppConstants.swift new file mode 100644 index 00000000..1ab8cedf --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Enum/AppConstants.swift @@ -0,0 +1,11 @@ +// +// AppConstants.swift +// ByeBoo-iOS +// +// Created by 이나연 on 7/4/26. +// + +enum AppConstants { + static let appStoreID = "6748205348" + static let appStoreURL = "itms-apps://itunes.apple.com/app/id\(appStoreID)" +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Enum/CommonQuestArchiveType.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Enum/CommonQuestArchiveType.swift index bde3216d..c31d33af 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Enum/CommonQuestArchiveType.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Enum/CommonQuestArchiveType.swift @@ -9,11 +9,17 @@ import Foundation import UIKit enum CommonQuestArchiveType { - case mine - case other + case myAnswer + case otherAnswer + case myComment + case otherComment enum Action { - case edit, delete, report, block + case questEdit + case commentEdit + case delete + case report + case block } struct Item { @@ -25,16 +31,26 @@ enum CommonQuestArchiveType { var items: [Item] { switch self { - case .mine: + case .myAnswer: return [ - Item(title: "수정하기", icon: .edit, color: .white, action: .edit), + Item(title: "수정하기", icon: .edit, color: .white, action: .questEdit), Item(title: "삭제하기", icon: .trash, color: .error300, action: .delete) ] - case .other: + case .myComment: + return [ + Item(title: "수정하기", icon: .edit, color: .white, action: .commentEdit), + Item(title: "삭제하기", icon: .trash, color: .error300, action: .delete) + ] + case .otherAnswer: return [ Item(title: "사용자 차단하기", icon: .block, color: .white, action: .block), Item(title: "게시글 신고하기", icon: .report, color: .error300, action: .report) ] + case .otherComment: + return [ + Item(title: "차단하기", icon: .block, color: .white, action: .block), + Item(title: "신고하기", icon: .report, color: .error300, action: .report) + ] } } } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Enum/ProfileIcon.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Enum/ProfileIcon.swift new file mode 100644 index 00000000..da77f388 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Enum/ProfileIcon.swift @@ -0,0 +1,32 @@ +// +// ProfileIcon.swift +// ByeBoo-iOS +// +// Created by 이나연 on 5/5/26. +// + +import UIKit + +enum ProfileIcon: String, CaseIterable { + case sad = "SADNESS" + case selfUnderstanding = "SELF_UNDERSTANDING" + case soso = "SO_SO" + case relieved = "RELIEVED" + + var image: UIImage { + switch self { + case .sad: + return .sadnessBadge + case .selfUnderstanding: + return .selfUnderstandingBadge + case .soso: + return .sosoBadge + case .relieved: + return .relievedBadge + } + } + + static func image(for iconString: String) -> UIImage? { + ProfileIcon(rawValue: iconString)?.image + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Extension/UITableView+.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Extension/UITableView+.swift index d3a196f4..d611132c 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Extension/UITableView+.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Extension/UITableView+.swift @@ -7,8 +7,25 @@ import UIKit +// intrinsicContentSize가 없어 콘텐츠 크기만큼 스스로 커지지 못한다. +// isScrollEnabled = false로 쓰고 contentSize를 intrinsicContentSize로 노출시켜, +// 바깥 UIScrollView 안에 스크롤 없이 끼워넣어도 콘텐츠 높이에 맞게 자동으로 커지게 한다. +final class SelfSizingTableView: UITableView { + override var contentSize: CGSize { + didSet { + if oldValue != contentSize { + invalidateIntrinsicContentSize() + } + } + } + + override var intrinsicContentSize: CGSize { + return contentSize + } +} + extension UITableView { - + func dequeueReusableCell(for indexPath: IndexPath) -> T { guard let cell = self.dequeueReusableCell( withIdentifier: T.identifier, diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Extension/UITextView+.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Extension/UITextView+.swift index b98111bf..74c2481a 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Extension/UITextView+.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Extension/UITextView+.swift @@ -37,4 +37,31 @@ extension UITextView { attributedText = NSAttributedString(string: targetText, attributes: attributes) typingAttributes = attributes } + + func applyTextViewStyle(style: FontManager, text: String, color: UIColor) { + self.applyByeBooFont( + style: style, + text: text, + color: color + ) + } + + func numberOfLine() -> Int { + guard !text.isEmpty else { return 0 } + let size = CGSize(width: frame.width, height: .infinity) + let estimatedSize = sizeThatFits(size) + + let lineHeight: CGFloat + if let paragraphStyle = attributedText?.attribute(.paragraphStyle, at: 0, effectiveRange: nil) as? NSParagraphStyle, + paragraphStyle.minimumLineHeight > 0 { + lineHeight = paragraphStyle.minimumLineHeight + } else if let font = attributedText?.attribute(.font, at: 0, effectiveRange: nil) as? UIFont { + lineHeight = font.lineHeight + } else { + lineHeight = self.font?.lineHeight ?? 0 + } + + guard lineHeight > 0 else { return 0 } + return Int(estimatedSize.height / lineHeight) + } } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Extension/UIViewController+.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Extension/UIViewController+.swift new file mode 100644 index 00000000..211947f1 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Extension/UIViewController+.swift @@ -0,0 +1,20 @@ +// +// UIViewController+.swift +// ByeBoo-iOS +// +// Created by 이나연 on 6/9/26. +// + +import UIKit + +extension UIViewController { + func presentBottomSheet(_ viewController: UIViewController, height: CGFloat) { + if let sheet = viewController.sheetPresentationController { + sheet.detents = [.custom { _ in height }] + sheet.prefersGrabberVisible = true + sheet.prefersScrollingExpandsWhenScrolledToEdge = false + sheet.preferredCornerRadius = 8 + } + self.present(viewController, animated: true) + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/View/Home/HomeHeaderView.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/View/Home/HomeHeaderView.swift index e7f678d0..0421934d 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/View/Home/HomeHeaderView.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/View/Home/HomeHeaderView.swift @@ -8,11 +8,11 @@ import UIKit final class HomeHeaderView: BaseView { - + + private(set) var noticeButton = UIButton() private(set) var homeStateView: HomeStateView = HomeStateView(state: .beforeJourneyStart) private let stackView = UIStackView() - private var journeyProgressView: JourneyProgressView? = nil private let helperButtonImage = UIImageView() let helperButton = UIButton() private let helperImageView = UIImageView() @@ -41,19 +41,25 @@ final class HomeHeaderView: BaseView { override func setUI() { addSubviews( + noticeButton, stackView, helperButton, helperImageView ) - + stackView.addArrangedSubviews( homeStateView ) } override func setLayout() { - stackView.snp.makeConstraints { + noticeButton.snp.makeConstraints { $0.top.equalToSuperview().inset(72.adjustedH) + $0.trailing.equalToSuperview().inset(24.adjustedH) + $0.size.equalTo(24.adjustedW) + } + stackView.snp.makeConstraints { + $0.top.equalTo(noticeButton.snp.bottom).offset(12.adjustedH) $0.horizontalEdges.equalToSuperview().inset(24.adjustedW) } helperButton.snp.makeConstraints { @@ -73,18 +79,6 @@ final class HomeHeaderView: BaseView { } extension HomeHeaderView { - func updateProgress(_ progress: Int) { - journeyProgressView?.updateProgress(progress) - } - - func updateName(_ name: String) { - journeyProgressView?.updateName(name) - } - - func updateJourney(_ title: String) { - journeyProgressView?.updateJourney(title) - } - func updateState(_ state: HomeState, _ journeyTitle: String? = nil) { helperImageView.alpha = 0 homeStateView.updateState(state, journeyTitle) @@ -93,41 +87,18 @@ extension HomeHeaderView { helperButton.alpha = 1 } - if state.hasProgress { - if journeyProgressView == nil { - journeyProgressView = JourneyProgressView() - } - - stackView.subviews.forEach { stackView.removeArrangedSubview($0) } - - guard let journeyProgressView else { return } - - stackView.addArrangedSubviews( - homeStateView, - journeyProgressView - ) - - stackView.snp.remakeConstraints { - $0.top.equalToSuperview().inset(72.adjustedH) - $0.horizontalEdges.equalToSuperview().inset(24.adjustedW) - $0.bottom.equalToSuperview() - } - helperButton.alpha = 0 - } else { - stackView.subviews.dropFirst().forEach { - stackView.removeArrangedSubview($0) - $0.removeFromSuperview() - } - journeyProgressView = nil - stackView.snp.remakeConstraints { - $0.top.equalToSuperview().inset(72.adjustedH) - $0.horizontalEdges.equalToSuperview().inset(24.adjustedW) - } - self.snp.makeConstraints { - $0.top.equalToSuperview() - $0.horizontalEdges.equalToSuperview() - $0.bottom.equalTo(helperButton.snp.bottom) - } + stackView.subviews.dropFirst().forEach { + stackView.removeArrangedSubview($0) + $0.removeFromSuperview() + } + stackView.snp.remakeConstraints { + $0.top.equalTo(noticeButton.snp.bottom).offset(12.adjustedH) + $0.horizontalEdges.equalToSuperview().inset(24.adjustedW) + } + self.snp.makeConstraints { + $0.top.equalToSuperview() + $0.horizontalEdges.equalToSuperview() + $0.bottom.equalTo(helperButton.snp.bottom) } } @@ -155,4 +126,9 @@ extension HomeHeaderView { func helperDidTap() { helperImageView.alpha = 0 } + + func updateNotice(isExist: Bool) { + let image: UIImage = isExist ? .existNotice : .emptyNotice + noticeButton.setImage(image, for: .normal) + } } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/View/Home/HomeView.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/View/Home/HomeView.swift index 685935bc..ec9b0d03 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/View/Home/HomeView.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/View/Home/HomeView.swift @@ -63,16 +63,6 @@ extension HomeView { speechBoxView.updateText(text, withAnimation: withAnimation) } - func updateProgressView( - name: String, - progress: Int, - journey: String - ) { - headerView.updateProgress(progress) - headerView.updateName(name) - headerView.updateJourney(journey) - } - func updateState(_ state: HomeState, _ journeyTitle: String? = nil) { headerView.updateState(state, journeyTitle) } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/View/Notice/EmptyNoticeView.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/View/Notice/EmptyNoticeView.swift new file mode 100644 index 00000000..175cc42e --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/View/Notice/EmptyNoticeView.swift @@ -0,0 +1,33 @@ +// +// EmptyNoticeView.swift +// ByeBoo-iOS +// +// Created by APPLE on 5/1/26. +// + +import UIKit + +final class EmptyNoticeView: BaseView { + + private let noNoticeLabel = UILabel() + + override func setStyle() { + noNoticeLabel.applyByeBooFont( + style: .body3R16, + text: "아직 도착한 알림이 없어요!", + color: .grayscale300 + ) + } + + override func setUI() { + addSubview(noNoticeLabel) + } + + override func setLayout() { + noNoticeLabel.snp.makeConstraints { + $0.top.equalToSuperview().inset(208.5.adjustedH) + $0.centerX.equalToSuperview() + $0.bottom.equalToSuperview().inset(397.adjustedH) + } + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/View/Notice/NoticeCardCell.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/View/Notice/NoticeCardCell.swift new file mode 100644 index 00000000..bf999b6a --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/View/Notice/NoticeCardCell.swift @@ -0,0 +1,107 @@ +// +// NoticeCardCell.swift +// ByeBoo-iOS +// +// Created by APPLE on 5/1/26. +// + +import UIKit + +final class NoticeCardCell: UITableViewCell { + + private let noticeImageView = UIImageView() + private let titleLabel = UILabel() + private let subtitleLabel = UILabel() + private let writtenTimeLabel = UILabel() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + setStyle() + setUI() + setLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setStyle() { + self.do { + $0.layer.cornerRadius = 12 + $0.selectionStyle = .none + } + titleLabel.applyByeBooFont( + style: .body1Sb16, + color: .grayscale200, + textAlignment: .left + ) + subtitleLabel.applyByeBooFont( + style: .body6R14, + color: .grayscale100, + textAlignment: .left + ) + writtenTimeLabel.applyByeBooFont( + style: .cap2R12, + color: .grayscale400, + textAlignment: .left + ) + } + + private func setUI() { + addSubviews( + noticeImageView, + titleLabel, + subtitleLabel, + writtenTimeLabel + ) + } + + private func setLayout() { + noticeImageView.snp.makeConstraints { + $0.top.equalToSuperview().inset(16.adjustedH) + $0.leading.equalToSuperview().inset(24.adjustedW) + $0.size.equalTo(24.adjustedW) + } + titleLabel.snp.makeConstraints { + $0.top.equalToSuperview().inset(16.adjustedH) + $0.leading.equalTo(noticeImageView.snp.trailing).offset(4.adjustedW) + $0.trailing.equalToSuperview().inset(24.adjustedW) + } + subtitleLabel.snp.makeConstraints { + $0.top.equalTo(noticeImageView.snp.bottom).offset(12.adjustedH) + $0.horizontalEdges.equalToSuperview().inset(24.adjustedW) + } + writtenTimeLabel.snp.makeConstraints { + $0.top.equalTo(subtitleLabel.snp.bottom).offset(20.adjustedH) + $0.horizontalEdges.equalToSuperview().inset(24.adjustedW) + $0.bottom.equalToSuperview().inset(16.adjustedH) + } + } +} + +extension NoticeCardCell { + + func bind( + isRead: Bool, + notificationType: NotificationType, + title: String, + subtitle: String, + writtenTime: String + ) { + self.backgroundColor = isRead ? .white5 : .primary30020 + noticeImageView.image = mapToImage(for: notificationType) + titleLabel.text = title + subtitleLabel.text = subtitle + writtenTimeLabel.text = writtenTime + } + + private func mapToImage(for notificationType: NotificationType) -> UIImage { + switch notificationType { + case .questOpen: + return .myOn + case .comment, .like: + return .commonJourney + } + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/View/Notice/NoticeCardsView.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/View/Notice/NoticeCardsView.swift new file mode 100644 index 00000000..180302c7 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/View/Notice/NoticeCardsView.swift @@ -0,0 +1,39 @@ +// +// NoticeCardsView.swift +// ByeBoo-iOS +// +// Created by APPLE on 5/1/26. +// + +import UIKit + +final class NoticeCardsView: BaseView { + + private(set) var cardTableView = UITableView() + + override func setStyle() { + cardTableView.do { + $0.backgroundColor = .grayscale900 + $0.separatorStyle = .none + $0.contentInset = UIEdgeInsets( + top: 0, + left: 0, + bottom: 24.adjustedH, + right: 0 + ) + $0.sectionHeaderTopPadding = 0 + } + } + + override func setUI() { + addSubview(cardTableView) + } + + override func setLayout() { + cardTableView.snp.makeConstraints { + $0.top.equalToSuperview() + $0.horizontalEdges.equalToSuperview() + $0.bottom.equalToSuperview().inset(80.adjustedH) + } + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/View/Notice/NoticeContentView.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/View/Notice/NoticeContentView.swift new file mode 100644 index 00000000..ebe0d9db --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/View/Notice/NoticeContentView.swift @@ -0,0 +1,43 @@ +// +// NoticeContentView.swift +// ByeBoo-iOS +// +// Created by APPLE on 4/30/26. +// + +import UIKit + +final class NoticeContentView: BaseView { + + private let noticeStackView = UIStackView() + private let emptyNoticeView = EmptyNoticeView() + private(set) var noticeCardsView = NoticeCardsView() + + override func setStyle() { + noticeStackView.do { + $0.axis = .vertical + } + } + + override func setUI() { + addSubview(noticeStackView) + noticeStackView.addArrangedSubviews( + emptyNoticeView, + noticeCardsView + ) + } + + override func setLayout() { + noticeStackView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + } +} + +extension NoticeContentView { + + func decideNoticeContent(isExistNotice: Bool) { + noticeCardsView.isHidden = !isExistNotice + emptyNoticeView.isHidden = isExistNotice + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/View/Notice/NoticesView.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/View/Notice/NoticesView.swift new file mode 100644 index 00000000..b3bc8f42 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/View/Notice/NoticesView.swift @@ -0,0 +1,62 @@ +// +// NoticesView.swift +// ByeBoo-iOS +// +// Created by APPLE on 4/30/26. +// + +import UIKit + +final class NoticesView: BaseView { + + private let noticesHeaderView = UIView() + private let disappearDeadlineLabel = UILabel() + private(set) var readAllButton = UIButton() + private(set) var contentView = NoticeContentView() + + override func setStyle() { + disappearDeadlineLabel.applyByeBooFont( + style: .cap1M12, + text: "최근 30일까지의 알림만 보여요", + color: .grayscale300 + ) + readAllButton.applyByeBooFont( + style: .cap1M12, + text: "모두 읽기", + color: .grayscale300 + ) + } + + override func setUI() { + addSubviews( + noticesHeaderView, + contentView + ) + + noticesHeaderView.addSubviews( + disappearDeadlineLabel, + readAllButton + ) + } + + override func setLayout() { + noticesHeaderView.snp.makeConstraints { + $0.top.equalTo(safeAreaLayoutGuide.snp.top).offset(16.adjustedH) + $0.horizontalEdges.equalToSuperview().inset(24.adjustedW) + $0.height.equalTo(16.adjustedH) + } + disappearDeadlineLabel.snp.makeConstraints { + $0.leading.equalToSuperview() + $0.verticalEdges.equalToSuperview() + } + readAllButton.snp.makeConstraints { + $0.trailing.equalToSuperview() + $0.verticalEdges.equalToSuperview() + } + contentView.snp.makeConstraints { + $0.top.equalTo(noticesHeaderView.snp.bottom).offset(20.adjustedH) + $0.horizontalEdges.equalToSuperview().inset(24.adjustedW) + $0.bottom.equalToSuperview().inset(24.adjustedH) + } + } +} 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 0606557a..09fa25a3 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/ViewController/HomeViewController.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/ViewController/HomeViewController.swift @@ -47,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, @@ -56,13 +59,20 @@ final class HomeViewController: BaseViewController { event: HomeEvents.Name.homePageView, properties: property.dictionary ) - - if isFirstVisit { isFirstVisit.toggle() } } override func setAddTarget() { setGesture() - rootView.headerView.helperButton.addTarget(self, action: #selector(helperDidTap), for: .touchUpInside) + rootView.headerView.helperButton.addTarget( + self, + action: #selector(helperDidTap), + for: .touchUpInside + ) + rootView.headerView.noticeButton.addTarget( + self, + action: #selector(noticeButtonDidTap), + for: .touchUpInside + ) } private func setGesture() { @@ -79,6 +89,7 @@ final class HomeViewController: BaseViewController { } extension HomeViewController { + @objc private func headerDidTap() { switch state { @@ -104,6 +115,15 @@ extension HomeViewController { self.navigationController?.pushViewController(tutorialViewController, animated: false) } + @objc + private func noticeButtonDidTap() { + let viewController = ViewControllerFactory.shared.makeNotificationsViewController() + viewController.hidesBottomBarWhenPushed = true + + self.navigationController?.setNavigationBarHidden(false, animated: false) + self.navigationController?.pushViewController(viewController, animated: false) + } + @objc private func boriDidTap() { @@ -125,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 @@ -136,7 +163,9 @@ extension HomeViewController: ToastPresentable, ToastErrorHandler { } } .store(in: &cancellables) - + } + + private func bindHomeState() { Publishers.CombineLatest3( viewModel.output.userResult, viewModel.output.journeyResult, @@ -148,14 +177,9 @@ extension HomeViewController: ToastPresentable, ToastErrorHandler { journey, state in switch (name, journey, state) { - case let (name, .success(journey), .success(state)): + case let (_, .success(journey), .success(state)): self?.rootView.updateState(state.currentStatus) self?.state = state.currentStatus - self?.rootView.updateProgressView( - name: name, - progress: state.questCount, - journey: journey.title - ) self?.journeyType = JourneyType.titleToEnum(journey.title) ?? .recording case let (_, .success(journey), .failure(.notFound)): self?.rootView.updateState(.beforeJourneyStart, journey.title) @@ -171,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 @@ -181,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/NotificationsViewController.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/ViewController/NotificationsViewController.swift new file mode 100644 index 00000000..e4281a55 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/ViewController/NotificationsViewController.swift @@ -0,0 +1,181 @@ +// +// 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 { + + private func bind() { + bindNotificationList() + bindAllNotificationsRead() + } + + private func bindNotificationList() { + viewModel.output.notificationListResult + .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) + } + + private func bindAllNotificationsRead() { + viewModel.output.readAllNotificationsResult + .receive(on: DispatchQueue.main) + .sink { [weak self] result in + switch result { + case .success: + self?.rootView.contentView.noticeCardsView.cardTableView.reloadData() + case .failure(let error): + self?.handleError(error) + } + } + .store(in: &cancellables) + } +} + +extension NotificationsViewController { + + @objc + private func readAllButtonDidTap() { + viewModel.action(.readAllNotificationsDidTap) + } +} + +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, + didSelectRowAt indexPath: IndexPath + ) { + viewModel.action(.notificationDidTap(at: indexPath.section)) + } + + func tableView( + _ tableView: UITableView, + viewForHeaderInSection section: Int + ) -> UIView? { + UIView() + } + + func tableView( + _ tableView: UITableView, + heightForHeaderInSection section: Int + ) -> CGFloat { + return section == 0 ? 0 : 24.adjustedH + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/ViewModel/CardJourneyViewModel.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/ViewModel/CardJourneyViewModel.swift index 0ae58e94..09a60df1 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/ViewModel/CardJourneyViewModel.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/ViewModel/CardJourneyViewModel.swift @@ -60,10 +60,7 @@ extension CardJourneyViewModel { let journey = try await fetchUserJourneyUseCase.execute() journeyResultSubject.send(.success(journey)) - } catch { - guard let error = error as? ByeBooError else { - return - } + } catch(let error as ByeBooError) { journeyResultSubject.send(.failure(error)) } } 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..9fe195b1 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Home/ViewModel/NotificationsViewModel.swift @@ -0,0 +1,161 @@ +// +// NotificationsViewModel.swift +// ByeBoo-iOS +// +// Created by 더스틴 on 6/7/26. +// + +import Combine +import UIKit + +final class NotificationsViewModel { + + private var cancellables = Set() + private var notifications: [NotificationEntity]? + + private let fetchNotificationListUseCase: FetchNotificationListUseCase + private let formatElapsedTimeUseCase: FormatElapsedTimeUseCase + private let readNotificationUseCase: ReadNotificationUseCase + private let readAllNotificationsUseCase: ReadAllNotificationsUseCase + + private(set) var output: Output + private var notificationListSubject = PassthroughSubject, Never>() + private var readAllNotificationsSubject = PassthroughSubject, Never>() + + init( + fetchNotificationListUseCase: FetchNotificationListUseCase, + formatElapsedTimeUseCase: FormatElapsedTimeUseCase, + readNotificationUseCase: ReadNotificationUseCase, + readAllNotificationsUseCase: ReadAllNotificationsUseCase + ) { + self.fetchNotificationListUseCase = fetchNotificationListUseCase + self.formatElapsedTimeUseCase = formatElapsedTimeUseCase + self.readNotificationUseCase = readNotificationUseCase + self.readAllNotificationsUseCase = readAllNotificationsUseCase + self.output = .init( + notificationListResult: notificationListSubject.eraseToAnyPublisher(), + readAllNotificationsResult: readAllNotificationsSubject.eraseToAnyPublisher() + ) + } +} + +extension NotificationsViewModel: ViewModelType { + enum Input { + case viewWillAppear + case notificationDidTap(at: Int) + case readAllNotificationsDidTap + } + + struct Output { + let notificationListResult: AnyPublisher, Never> + let readAllNotificationsResult: AnyPublisher, Never> + } + + func action(_ trigger: Input) { + switch trigger { + case .viewWillAppear: + fetchNotificationList() + case .notificationDidTap(let index): + readNotification(at: index) + case .readAllNotificationsDidTap: + readAllNotifications() + } + } +} + +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) + } + + func readNotification(at index: Int) { + guard let notification = notifications?[index] else { + return + } + + if !notification.isRead { + Task { + do { + let _ = try await readNotificationUseCase.execute(for: notification.notificationID) + } catch { + ByeBooLogger.error(error) + } + } + } + + handleNotification(at: index) + } + + func readAllNotifications() { + guard let isAllRead = notifications?.allSatisfy({ $0.isRead }), + !isAllRead + else { + return + } + + Task { + do { + let _ = try await readAllNotificationsUseCase.execute() + self.notifications = notifications?.map { $0.markAsRead() } + readAllNotificationsSubject.send(.success(())) + } catch { + guard let error = error as? ByeBooError else { + return + } + readAllNotificationsSubject.send(.failure(error)) + } + } + } +} + +extension NotificationsViewModel { + + var notificatinosCount: Int { + notifications?.count ?? 0 + } + + func getNotification(at index: Int) -> NotificationEntity? { + guard let notifications, + notifications.indices.contains(index) + else { + return nil + } + + return notifications[index] + } + + private func handleNotification(at index: Int) { + guard let landingURL = getLandingURL(at: index), + let destination = DeepLinkParser.parse(from: landingURL) + else { + return + } + + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first(where: { $0.isKeyWindow }) { + + destination.navigate(from: window) + } + } + + private func getLandingURL(at index: Int) -> String? { + let notification = getNotification(at: index) + return notification?.landingURL + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Information/ViewModel/InformationViewModel.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Information/ViewModel/InformationViewModel.swift index 00f5a582..fa4b7c0b 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Information/ViewModel/InformationViewModel.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Information/ViewModel/InformationViewModel.swift @@ -58,8 +58,8 @@ final class InformationViewModel { questStyle: questStyle.key ) getUserName() - } catch { - userInformationSubject.send(.failure(error as! ByeBooError)) + } catch(let error as ByeBooError) { + userInformationSubject.send(.failure(error)) } } } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Login/View/LoginView.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Login/View/LoginView.swift index 64427a21..2bdd49dd 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Login/View/LoginView.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Login/View/LoginView.swift @@ -35,11 +35,13 @@ final class LoginView: BaseView { appleLoginButton.do { $0.setImage(.appleLoginButton, for: .normal) $0.alpha = 0 + $0.isExclusiveTouch = true } - + kakaoLoginButton.do { $0.setImage(.kakaoLoginButton, for: .normal) $0.alpha = 0 + $0.isExclusiveTouch = true } } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Login/ViewController/LoginViewController.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Login/ViewController/LoginViewController.swift index 8ad6c509..fb2b2526 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Login/ViewController/LoginViewController.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Login/ViewController/LoginViewController.swift @@ -64,12 +64,14 @@ extension LoginViewController{ @objc private func kakaoLoginButtonDidTap() { rootView.kakaoLoginButton.isUserInteractionEnabled = false + rootView.appleLoginButton.isUserInteractionEnabled = false self.platform = .KAKAO viewModel.action(.socialLoginButtonDidTap(platform: .KAKAO)) } - + @objc private func appleLoginButtonDidTap() { + rootView.kakaoLoginButton.isUserInteractionEnabled = false rootView.appleLoginButton.isUserInteractionEnabled = false self.platform = .APPLE viewModel.action(.socialLoginButtonDidTap(platform: .APPLE)) diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Login/ViewController/SplashViewController.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Login/ViewController/SplashViewController.swift index 36807631..c4e2f421 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Login/ViewController/SplashViewController.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Login/ViewController/SplashViewController.swift @@ -9,26 +9,33 @@ import Combine import UIKit final class SplashViewController: BaseViewController { - + private let rootView = SplashView() private let viewModel: SplashViewModel private var cancellables = Set() - + private var isFirstLaunch = true + init(viewModel: SplashViewModel) { self.viewModel = viewModel super.init(nibName: nil, bundle: nil) } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + override func viewDidLoad() { view = rootView viewModel.action(.viewDidLoad) bind() - setAddTarget() + NotificationCenter.default.addObserver( + self, + selector: #selector(appDidBecomeActive), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { [weak self] in guard let self = self else { return } if self.cancellables.isEmpty { @@ -39,8 +46,27 @@ final class SplashViewController: BaseViewController { } extension SplashViewController { - private func bind() { + bindCheckForceUpdate() + bindAutoLogin() + } + + private func bindCheckForceUpdate() { + viewModel.output.forceUpdatePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] result in + guard let self else { return } + switch result { + case true: + self.presentForceUpdateModal() + case false: + ByeBooLogger.debug("강제업데이트 필요 없음") + } + } + .store(in: &cancellables) + } + + private func bindAutoLogin() { viewModel.output.autoLoginPublisher .receive(on: DispatchQueue.main) .sink { result in @@ -77,6 +103,29 @@ extension SplashViewController { } .store(in: &cancellables) } + + private func presentForceUpdateModal() { + let modal = ModalBuilder( + modalView: ForceUpdateModalView(), + action: openAppstore, + rootViewController: self + ) + modal.present() + } + + private func openAppstore() { + guard let url = URL(string: AppConstants.appStoreURL) else { return } + UIApplication.shared.open(url) + } + + @objc + private func appDidBecomeActive() { + guard !isFirstLaunch else { + isFirstLaunch = false + return + } + viewModel.action(.viewDidLoad) + } } extension SplashViewController { diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Login/ViewModel/LoginViewModel.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Login/ViewModel/LoginViewModel.swift index 17329ca4..334ee70a 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Login/ViewModel/LoginViewModel.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Login/ViewModel/LoginViewModel.swift @@ -23,7 +23,8 @@ final class LoginViewModel: NSObject { } private var cancellables = Set() - + private var isSocialLoginInProgress = false + private let socialLoginUseCase: SocialLoginUseCase private let getIsRegisteredUseCase: GetIsRegisteredUseCase private let getUserIDUseCase: GetUserIDUseCase @@ -60,17 +61,22 @@ extension LoginViewModel { extension LoginViewModel { private func socialLogin(platform: LoginPlatform) { + guard !isSocialLoginInProgress else { + ByeBooLogger.debug("이미 진행중인 요청 있음") + return + } + isSocialLoginInProgress = true + Task { + defer { isSocialLoginInProgress = false } + do { let _ = try await socialLoginUseCase.execute(platform: platform) socialLoginAuthSubject.send(.success(())) getIsRegistered() getUserID() - } catch { - guard let error = error as? ByeBooError else { - return - } - ByeBooLogger.error(error as ByeBooError) + } catch(let error as ByeBooError) { + ByeBooLogger.error(error) socialLoginAuthSubject.send(.failure(error)) } } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Login/ViewModel/SplashViewModel.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Login/ViewModel/SplashViewModel.swift index bbfc1993..284a05e1 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Login/ViewModel/SplashViewModel.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Login/ViewModel/SplashViewModel.swift @@ -9,22 +9,27 @@ import Combine import Foundation final class SplashViewModel { - + private var autoLoginSubject: PassthroughSubject, Never> = .init() - + private var forceUpdateSubject: PassthroughSubject = .init() + var output: Output { Output( - autoLoginPublisher: autoLoginSubject.eraseToAnyPublisher() + autoLoginPublisher: autoLoginSubject.eraseToAnyPublisher(), + forceUpdatePublisher: forceUpdateSubject.eraseToAnyPublisher() ) } - + private var cancellables = Set() private let autoLoginUseCase: AutoLoginUseCase - + private let checkForceUpdateUseCase: CheckForceUpdateUseCase + init( - autoLoginUseCase: AutoLoginUseCase + autoLoginUseCase: AutoLoginUseCase, + checkForceUpdateUseCase: CheckForceUpdateUseCase ) { self.autoLoginUseCase = autoLoginUseCase + self.checkForceUpdateUseCase = checkForceUpdateUseCase } } @@ -36,18 +41,33 @@ extension SplashViewModel { struct Output { let autoLoginPublisher: AnyPublisher, Never> + let forceUpdatePublisher: AnyPublisher } func action(_ trigger: Input) { switch trigger { case .viewDidLoad: - autoLogin() + checkForceUpdate() } } } extension SplashViewModel { + private func checkForceUpdate() { + Task { + do { + if await checkForceUpdateUseCase.execute() { + forceUpdateSubject.send(true) + ByeBooLogger.debug("강제 업데이트 필요") + } else { + forceUpdateSubject.send(false) + autoLogin() + } + } + } + } + private func autoLogin() { ByeBooLogger.debug("자동로그인 실행") Task { @@ -60,13 +80,10 @@ extension SplashViewModel { ByeBooLogger.debug("자동로그인 실패") autoLoginSubject.send(.failure((.noData))) } - } catch { - guard let error = error as? ByeBooError else { - return - } + } catch(let error as ByeBooError) { autoLoginSubject.send(.failure((.noData))) ByeBooLogger.debug(ByeBooError.networkConnect) - ByeBooLogger.error(error as ByeBooError) + ByeBooLogger.error(error) } } } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/MyPage/ViewModel/BlockedUserListViewModel.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/MyPage/ViewModel/BlockedUserListViewModel.swift index 08c369fb..037ca3fb 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/MyPage/ViewModel/BlockedUserListViewModel.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/MyPage/ViewModel/BlockedUserListViewModel.swift @@ -70,11 +70,8 @@ extension BlockedUserListViewModel { self.blockedUsers = try await getBlockedUsersListUseCase.execute() ByeBooLogger.debug("차단한 유저 \(blockedUsers)") getBlockedUsersListSubject.send(.success(blockedUsers)) - } catch { - guard let error = error as? ByeBooError else { - return - } - ByeBooLogger.error(error as ByeBooError) + } catch(let error as ByeBooError) { + ByeBooLogger.error(error) getBlockedUsersListSubject.send(.failure(error)) } } @@ -87,11 +84,8 @@ extension BlockedUserListViewModel { try await deleteBlockedUserUseCase.execute(blockID: blockID) ByeBooLogger.debug("차단 완료 \(blockID)") deleteBlockUserSubject.send(.success(())) - } catch { - guard let error = error as? ByeBooError else { - return - } - ByeBooLogger.error(error as ByeBooError) + } catch(let error as ByeBooError) { + ByeBooLogger.error(error) deleteBlockUserSubject.send(.failure(error)) } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/MyPage/ViewModel/LookBackJourneyViewModel.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/MyPage/ViewModel/LookBackJourneyViewModel.swift index ea43cc4b..438e65ae 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/MyPage/ViewModel/LookBackJourneyViewModel.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/MyPage/ViewModel/LookBackJourneyViewModel.swift @@ -51,11 +51,8 @@ extension LookBackJourneyViewModel { do { let journeys = try await useCase.execute() lookBackJourneySubject.send(.success(journeys)) - } catch { - guard let error = error as? ByeBooError else { - return - } - ByeBooLogger.error(error as ByeBooError) + } catch(let error as ByeBooError) { + ByeBooLogger.error(error) lookBackJourneySubject.send(.failure(error)) } } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/MyPage/ViewModel/MyPageViewModel.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/MyPage/ViewModel/MyPageViewModel.swift index c76e2098..ccca3b78 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/MyPage/ViewModel/MyPageViewModel.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/MyPage/ViewModel/MyPageViewModel.swift @@ -99,13 +99,10 @@ extension MyPageViewModel { private func logout() { Task { do { - try await logoutUseCase.execute() + let _ = try await logoutUseCase.execute() logoutResultSubject.send(.success(())) - } catch { - guard let error = error as? ByeBooError else { - return - } - ByeBooLogger.error(error as ByeBooError) + } catch(let error as ByeBooError) { + ByeBooLogger.error(error) logoutResultSubject.send(.failure(error)) } } @@ -114,13 +111,10 @@ extension MyPageViewModel { private func withdraw() { Task { do { - try await withdrawUseCase.execute() + let _ = try await withdrawUseCase.execute() withdrawResultSubject.send(.success(())) - } catch { - guard let error = error as? ByeBooError else { - return - } - ByeBooLogger.error(error as ByeBooError) + } catch(let error as ByeBooError) { + ByeBooLogger.error(error) withdrawResultSubject.send(.failure(error)) } } @@ -131,11 +125,8 @@ extension MyPageViewModel { do { let result = try await changeNotificationPermissionUseCase.execute() notificationResultSubject.send(.success(result)) - } catch { - guard let error = error as? ByeBooError else { - return - } - ByeBooLogger.error(error as ByeBooError) + } catch(let error as ByeBooError) { + ByeBooLogger.error(error) notificationResultSubject.send(.failure(error)) } } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/MyPage/ViewModel/NewJourneyViewModel.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/MyPage/ViewModel/NewJourneyViewModel.swift index 221f883b..2b5e7b73 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/MyPage/ViewModel/NewJourneyViewModel.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/MyPage/ViewModel/NewJourneyViewModel.swift @@ -52,11 +52,8 @@ extension NewJourneyViewModel { do { let journeys = try await getNewJourneyUseCase.execute() newJourneySubject.send(.success(journeys)) - } catch { - guard let error = error as? ByeBooError else { - return - } - ByeBooLogger.error(error as ByeBooError) + } catch(let error as ByeBooError) { + ByeBooLogger.error(error) newJourneySubject.send(.failure(error)) } } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Offboarding/View/FinishJourneyView.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Offboarding/View/FinishJourneyView.swift index d992fd43..4dac8aa3 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Offboarding/View/FinishJourneyView.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Offboarding/View/FinishJourneyView.swift @@ -19,7 +19,7 @@ final class FinishJourneyView: BaseView { private let thirdTextLabel = UILabel() private let characterLottie = LottieAnimationView(name: "bori_cake") - let startButton = ByeBooButton(titleText: "새로운 이별 극복 여정 시작하기", type: .enabled) + let startButton = ByeBooButton(titleText: "새로운 여정 시작하기", type: .enabled) let lookBackButton = ByeBooButton(titleText: "완료한 여정 다시보기", type: .sub) let backHomeLabel = UILabel() diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Offboarding/ViewController/FinishJourneyViewController.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Offboarding/ViewController/FinishJourneyViewController.swift index 13b3d677..9f29a295 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Offboarding/ViewController/FinishJourneyViewController.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Offboarding/ViewController/FinishJourneyViewController.swift @@ -99,10 +99,7 @@ extension FinishJourneyViewController { extension FinishJourneyViewController: Dismissible { func close() { - if let tabBarController { - navigationController?.popViewController(animated: false) - ViewControllerUtils.changeSelectedIndex(index: 0) - } + navigationController?.popViewController(animated: false) } } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Offboarding/ViewModel/CompletedQuestsViewModel.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Offboarding/ViewModel/CompletedQuestsViewModel.swift index 7749f2ef..ab7441d2 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Offboarding/ViewModel/CompletedQuestsViewModel.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Offboarding/ViewModel/CompletedQuestsViewModel.swift @@ -47,12 +47,8 @@ final class CompletedQuestsViewModel { ) self.questsEntity = completedQuestsEntity questsSubject.send(.success(completedQuestsEntity)) - } catch { - questsSubject.send( - .failure( - error as? ByeBooError ?? ByeBooError.unknownError - ) - ) + } catch(let error as ByeBooError) { + questsSubject.send(.failure(error)) } } } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/View/CommonQuest/Cells/CommentTableViewCell.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/View/CommonQuest/Cells/CommentTableViewCell.swift new file mode 100644 index 00000000..362f8639 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/View/CommonQuest/Cells/CommentTableViewCell.swift @@ -0,0 +1,342 @@ +// +// CommentCellTableViewCell.swift +// ByeBoo-iOS +// +// Created by 이나연 on 5/4/26. +// + +import UIKit + +protocol CommentProtocol: AnyObject { + func moreLabelDidTap(commentID: Int) + func replyIconDidTap(commentID: Int) + func menuButtonDidTap(commentID: Int, isMyComment: Bool) +} + +extension CommentProtocol { + func replyIconDidTap(commentID: Int) { } +} + +final class CommentTableViewCell: UITableViewCell { + + weak var delegate: CommentProtocol? + + private var replyCommentArrow = UIImageView() + private var replyCommentContainer = UIView() + private let profileIcon = UIImageView(image: .relievedBadge) + private let nicknameLabel = UILabel() + private let dateLabel = UILabel() + private let menuButton = UIButton() + private let commentTextView = UITextView() + private var moreLabel = UILabel() + private var replyCountContainer = UIStackView() + private var replyCommentIcon = UIImageView() + private var replyCountLabel = UILabel() + + private var replyCount: Int = 0 + private var commentID: Int = 0 + private var content: String = "" + private var didSetReplyLayout = false + private var didSetCommentLayout = false + + private var isMyComment: Bool = false + + private var showAllText: Bool = false + private var lastTruncationWidth: CGFloat = -1 + + override init( + style: UITableViewCell.CellStyle, + reuseIdentifier: String? + ) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + setUI() + setStyle() + setLayout() + setAddTarget() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + + // bounds.width가 이 기기 폭으로 확정된 뒤에만 계산해야 기기별 오차가 없다. + let width = commentTextView.bounds.width + guard width > 0, width != lastTruncationWidth else { return } + lastTruncationWidth = width + + updateTextTruncation() + } + + // exclusionPaths 변경으로 layoutSubviews가 재호출돼도 width는 안 바뀌므로 위 guard에서 걸러진다. + private func updateTextTruncation() { + commentTextView.textContainer.maximumNumberOfLines = 0 + commentTextView.textContainer.exclusionPaths = [] + + if showAllText { + commentTextView.invalidateIntrinsicContentSize() + moreLabel.isHidden = true + } else { + applyStyleWhenHideText() + } + } + + private func setUI() { + contentView.addSubviews(profileIcon, nicknameLabel, dateLabel, menuButton, commentTextView, moreLabel) + } + + private func setStyle() { + backgroundColor = .grayscale900 + selectionStyle = .none + + replyCommentArrow.do { + $0.image = .replyArrow + } + + nicknameLabel.do { + $0.applyByeBooFont(style: .body5M14, color: .grayscale200) + } + + dateLabel.do { + $0.applyByeBooFont(style: .cap2R12, color: .grayscale400) + } + + menuButton.do { + $0.setImage(.menu, for: .normal) + } + + commentTextView.do { + $0.isEditable = false + $0.isScrollEnabled = false + $0.isUserInteractionEnabled = false + $0.isSelectable = false + $0.backgroundColor = .clear + $0.textContainerInset = .zero + $0.textContainer.lineBreakMode = .byTruncatingTail + $0.textContainer.lineFragmentPadding = 0 + $0.applyByeBooFont(style: .body6R14, color: .grayscale100) + } + + moreLabel.do { + $0.applyByeBooFont(style: .body6R14, text: "더보기", color: .grayscale400) + $0.backgroundColor = .grayscale900 + $0.isUserInteractionEnabled = true + } + + replyCountContainer.do { + $0.axis = .horizontal + $0.spacing = 4.adjustedW + } + + replyCommentIcon.do { + $0.image = .comment.withRenderingMode(.alwaysTemplate) + $0.isUserInteractionEnabled = true + $0.tintColor = .grayscale100 + } + + replyCountLabel.do { + $0.applyByeBooFont(style: .cap2R12, text: String(replyCount), color: .grayscale100) + } + } + + private func setLayout() { + nicknameLabel.snp.makeConstraints { + $0.top.equalToSuperview().inset(16.adjustedH) + $0.leading.equalTo(profileIcon.snp.trailing).offset(4.adjustedW) + } + dateLabel.snp.makeConstraints { + $0.top.equalToSuperview().inset(18.5.adjustedH) + $0.leading.equalTo(nicknameLabel.snp.trailing).offset(4) + } + menuButton.snp.makeConstraints { + $0.top.equalToSuperview().inset(16.adjustedH) + $0.trailing.equalToSuperview() + $0.size.equalTo(24.adjustedH) + } + moreLabel.snp.makeConstraints { + $0.bottom.equalTo(commentTextView.snp.bottom) + $0.trailing.equalTo(commentTextView.snp.trailing).offset(-20.adjustedW) + } + } + + private func setCommentListLayout() { + guard !didSetCommentLayout else { return } + didSetCommentLayout = true + + contentView.addSubview(replyCountContainer) + replyCountContainer.addArrangedSubviews(replyCommentIcon, replyCountLabel) + + profileIcon.snp.makeConstraints { + $0.top.equalToSuperview().inset(16.adjustedH) + $0.leading.equalToSuperview() + $0.size.equalTo(20.adjustedH) + } + commentTextView.snp.makeConstraints { + $0.top.equalTo(profileIcon.snp.bottom).offset(4.adjustedH) + $0.leading.equalTo(profileIcon.snp.leading).offset(20) + $0.trailing.equalToSuperview().inset(24.adjustedW) + $0.bottom.equalToSuperview().inset(44.adjustedH) + } + replyCountContainer.snp.makeConstraints { + $0.top.equalTo(commentTextView.snp.bottom).offset(8.adjustedH) + $0.leading.equalTo(commentTextView.snp.leading) + $0.height.equalTo(20.adjustedH) + $0.bottom.equalToSuperview().inset(16.adjustedH) + } + replyCommentIcon.snp.makeConstraints { + $0.size.equalTo(20.adjustedH) + } + } + + private func setReplySheetLayout() { + guard !didSetReplyLayout else { return } + didSetReplyLayout = true + + contentView.addSubviews(replyCommentArrow) + + replyCommentArrow.snp.makeConstraints { + $0.top.equalToSuperview().inset(16.adjustedH) + $0.leading.equalToSuperview() + $0.size.equalTo(24.adjustedH) + } + profileIcon.snp.makeConstraints { + $0.top.equalToSuperview().inset(16.adjustedH) + $0.leading.equalTo(replyCommentArrow.snp.trailing) + $0.size.equalTo(20.adjustedH) + } + commentTextView.snp.remakeConstraints { + $0.top.equalTo(profileIcon.snp.bottom).offset(4.adjustedH) + $0.leading.equalTo(profileIcon.snp.leading).offset(20) + $0.trailing.equalToSuperview().inset(24.adjustedW) + $0.bottom.equalToSuperview().inset(16.adjustedH) + } + } + + private func setAddTarget() { + let moreLabelTapRecognizer = UITapGestureRecognizer(target: self, action: #selector(moreLabelDidTap(_:))) + moreLabel.addGestureRecognizer(moreLabelTapRecognizer) + + let replyTapRecognizer = UITapGestureRecognizer(target: self, action: #selector(replyIconDidTap(_:))) + replyCommentIcon.addGestureRecognizer(replyTapRecognizer) + + menuButton.addTarget(self, action: #selector(menuButtonDidTap), for: .touchUpInside) + } +} + +extension CommentTableViewCell { + func configure( + isMyComment: Bool, + commentID: Int, + replyCount: Int? = nil, + writer: String, + profileIcon: UIImage, + writtenAt: String, + content: String, + showAllText: Bool, + isReplySheet: Bool + ) { + if let replyCount { + setCommentListLayout() + updateReplyCount(replyCount: replyCount) + } else { + setReplySheetLayout() + } + + if isReplySheet { + replyCommentIcon.tintColor = .grayscale600 + replyCountLabel.textColor = .grayscale600 + } + + self.isMyComment = isMyComment + self.commentID = commentID + nicknameLabel.text = writer + self.profileIcon.image = profileIcon + dateLabel.text = writtenAt + self.content = content + self.showAllText = showAllText + commentTextView.applyTextViewStyle(style: .body6R14, text: content, color: .grayscale100) + + commentTextView.textContainer.maximumNumberOfLines = 0 + commentTextView.textContainer.exclusionPaths = [] + + // setNeedsLayout만으로는 부족하다: commentListView가 스크롤 없는 self-sizing 테이블이라 + // 일반 테이블처럼 셀이 화면에 나타날 때 자연스럽게 layoutSubviews가 재호출되지 않는다. + // layoutIfNeeded로 직접 트리거해야 truncation 계산(layoutSubviews)이 그 자리에서 실행된다. + lastTruncationWidth = -1 + layoutIfNeeded() + } + + func updateReplyCount(replyCount: Int) { + self.replyCount = replyCount + replyCountLabel.text = "\(replyCount)" + } +} + +extension CommentTableViewCell { + private func applyStyleWhenHideText() { + let textContainer = commentTextView.textContainer + let layoutManager = commentTextView.layoutManager + + // textContainer.size는 UITextView가 자기 layoutSubviews에서 뒤늦게 동기화하므로 직접 맞춰준다. + textContainer.size = CGSize(width: commentTextView.bounds.width, height: .greatestFiniteMagnitude) + textContainer.maximumNumberOfLines = 5 + + // glyphRange(for:)는 잘려도 전체 glyph 수를 그대로 반환해 truncation 판단에 못 쓴다. + // 마지막 줄이 실제로 잘렸는지는 truncatedGlyphRange(inLineFragmentForGlyphAt:)로 확인한다. + layoutManager.ensureLayout(for: textContainer) + let glyphRange = layoutManager.glyphRange(for: textContainer) + var isTruncated = false + if glyphRange.length > 0 { + let lastGlyphIndex = NSMaxRange(glyphRange) - 1 + let truncatedRange = layoutManager.truncatedGlyphRange(inLineFragmentForGlyphAt: lastGlyphIndex) + isTruncated = truncatedRange.location != NSNotFound + } + + moreLabel.isHidden = !isTruncated + + guard isTruncated else { + commentTextView.textContainer.exclusionPaths = [] + return + } + + let moreLabelWidth: CGFloat = moreLabel.intrinsicContentSize.width + let contentHeight = commentTextView.sizeThatFits( + CGSize(width: commentTextView.bounds.width, height: .infinity) + ).height + + let lineHeight: CGFloat + if let paragraphStyle = commentTextView.attributedText?.attribute(.paragraphStyle, at: 0, effectiveRange: nil) + as? NSParagraphStyle, + paragraphStyle.minimumLineHeight > 0 { + lineHeight = paragraphStyle.minimumLineHeight + } else { + lineHeight = commentTextView.font?.lineHeight ?? 0 + } + + let exclusionRect = CGRect( + x: commentTextView.bounds.width - moreLabelWidth - 10, + y: contentHeight - lineHeight, + width: moreLabelWidth, + height: lineHeight + ) + commentTextView.textContainer.exclusionPaths = [UIBezierPath(rect: exclusionRect)] + } + + @objc + private func moreLabelDidTap(_ tapRecognizer: UITapGestureRecognizer) { + delegate?.moreLabelDidTap(commentID: commentID) + } + + @objc + private func replyIconDidTap(_ tapRecognizer: UITapGestureRecognizer) { + delegate?.replyIconDidTap(commentID: commentID) + } + + @objc + private func menuButtonDidTap() { + delegate?.menuButtonDidTap(commentID: commentID, isMyComment: isMyComment) + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/View/CommonQuest/Cells/CommonQuestAnswerCell.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/View/CommonQuest/Cells/CommonQuestAnswerCell.swift index 7419c854..4ca91827 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/View/CommonQuest/Cells/CommonQuestAnswerCell.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/View/CommonQuest/Cells/CommonQuestAnswerCell.swift @@ -16,8 +16,7 @@ final class CommonQuestAnswerCell: UITableViewCell { private let containerView = UIView() private let userIconView = UIImageView() private let userNicknameLabel = UILabel() - private let answerContentLabel = UILabel() - private let writtenDateLabel = UILabel() + private(set) var questContentView = QuestContentView() override init( style: UITableViewCell.CellStyle, @@ -47,24 +46,14 @@ final class CommonQuestAnswerCell: UITableViewCell { style: .body6R14, color: .grayscale200 ) - answerContentLabel.applyByeBooFont( - style: .body3R16, - color: .grayscale100, - numberOfLines: 0 - ) - writtenDateLabel.applyByeBooFont( - style: .body6R14, - color: .grayscale400 - ) } private func setUI() { - addSubview(containerView) + contentView.addSubview(containerView) containerView.addSubviews( userIconView, userNicknameLabel, - answerContentLabel, - writtenDateLabel + questContentView ) } @@ -84,14 +73,9 @@ final class CommonQuestAnswerCell: UITableViewCell { $0.leading.equalTo(userIconView.snp.trailing).offset(4.adjustedW) $0.centerY.equalTo(userIconView.snp.centerY) } - answerContentLabel.snp.makeConstraints { - $0.top.equalTo(userIconView.snp.bottom).offset(12.adjustedH) + questContentView.snp.makeConstraints { + $0.top.equalTo(userNicknameLabel.snp.bottom).offset(12.adjustedH) $0.horizontalEdges.equalToSuperview().inset(24.adjustedW) - $0.height.equalTo(47.adjustedH) - } - writtenDateLabel.snp.makeConstraints { - $0.top.equalTo(answerContentLabel.snp.bottom).offset(20.adjustedH) - $0.leading.equalToSuperview().inset(24.adjustedW) $0.bottom.equalToSuperview().inset(16.adjustedH) } } @@ -108,9 +92,16 @@ extension CommonQuestAnswerCell { userIconView.image = profileIcon } userNicknameLabel.text = answer.writer - answerContentLabel.text = answer.content - writtenDateLabel.text = writtenAt answerID = answer.answerID + questContentView.configure( + answerID: answer.answerID, + content: answer.content, + writtenAt: writtenAt, + isLiked: answer.isLiked, + likeCount: answer.likeCount, + commentCount: answer.commentCount, + showAllText: false + ) } func getAnswewrID() -> Int? { diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/View/CommonQuest/Cells/CommonQuestMyAnswerCell.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/View/CommonQuest/Cells/CommonQuestMyAnswerCell.swift index 1df86b35..9f6164c4 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/View/CommonQuest/Cells/CommonQuestMyAnswerCell.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/View/CommonQuest/Cells/CommonQuestMyAnswerCell.swift @@ -13,8 +13,7 @@ final class CommonQuestMyAnswerCell: UITableViewCell { private let questionView = UIView() private let questionMarkLabel = UILabel() private let questionContentLabel = UILabel() - private let answerContentLabel = UILabel() - private let writtenDateLabel = UILabel() + private(set) var questContentView = QuestContentView() override init( style: UITableViewCell.CellStyle, @@ -53,23 +52,13 @@ final class CommonQuestMyAnswerCell: UITableViewCell { ) $0.lineBreakStrategy = [] } - answerContentLabel.applyByeBooFont( - style: .body3R16, - color: .grayscale100, - numberOfLines: 2 - ) - writtenDateLabel.applyByeBooFont( - style: .cap2R12, - color: .grayscale400 - ) } private func setUI() { contentView.addSubview(containerView) containerView.addSubviews( questionView, - answerContentLabel, - writtenDateLabel + questContentView ) questionView.addSubviews( questionMarkLabel, @@ -86,25 +75,19 @@ final class CommonQuestMyAnswerCell: UITableViewCell { $0.horizontalEdges.equalToSuperview().inset(24.adjustedW) } questionMarkLabel.snp.makeConstraints { - $0.top.equalTo(questionContentLabel.snp.top) + $0.top.equalToSuperview() $0.leading.equalToSuperview() $0.width.equalTo(17.adjustedW) } questionContentLabel.snp.makeConstraints { - $0.verticalEdges.equalToSuperview() + $0.top.equalToSuperview() $0.leading.equalTo(questionMarkLabel.snp.trailing).offset(4.adjustedW) $0.trailing.equalToSuperview() } - answerContentLabel.snp.makeConstraints { - $0.top.equalTo(questionView.snp.bottom).offset(12.adjustedH) + questContentView.snp.makeConstraints { + $0.top.equalTo(questionContentLabel.snp.bottom).offset(12.adjustedH) $0.horizontalEdges.equalToSuperview().inset(24.adjustedW) - $0.height.equalTo(47.adjustedH) - } - writtenDateLabel.snp.makeConstraints { - $0.top.equalTo(answerContentLabel.snp.bottom).offset(20.adjustedH) - $0.leading.equalToSuperview().inset(24.adjustedW) $0.bottom.equalToSuperview().inset(16.adjustedH) - $0.height.equalTo(16.adjustedH) } } } @@ -112,12 +95,23 @@ final class CommonQuestMyAnswerCell: UITableViewCell { extension CommonQuestMyAnswerCell { func bind( + answerID: Int, question: String, content: String, - writtenAt: String + writtenAt: String, + isLiked: Bool, + likeCount: Int, + commentCount: Int ) { questionContentLabel.text = question - answerContentLabel.text = content - writtenDateLabel.text = writtenAt + questContentView.configure( + answerID: answerID, + content: content, + writtenAt: writtenAt, + isLiked: isLiked, + likeCount: likeCount, + commentCount: commentCount, + showAllText: true + ) } } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/View/CommonQuest/Common/CommentDataSource.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/View/CommonQuest/Common/CommentDataSource.swift new file mode 100644 index 00000000..3b32e980 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/View/CommonQuest/Common/CommentDataSource.swift @@ -0,0 +1,22 @@ +// +// CommentDataSource.swift +// ByeBoo-iOS +// +// Created by 이나연 on 5/8/26. +// + +import Foundation + +enum CommentSection: Hashable { + case main +} + +enum ReplySection: Hashable { + case comment + case replies +} + +struct CommentItem: Hashable { + let entity: CommonQuestCommentEntity + var showAllText: Bool +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/View/CommonQuest/Common/CommentTextView.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/View/CommonQuest/Common/CommentTextView.swift new file mode 100644 index 00000000..0862dbca --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/View/CommonQuest/Common/CommentTextView.swift @@ -0,0 +1,247 @@ +// +// CommentTextView.swift +// ByeBoo-iOS +// +// Created by 이나연 on 5/4/26. +// + +import UIKit + +protocol CommonQuestCommentProtcol: AnyObject { + func postComment(content: String) + func editComment(content: String) +} + +final class CommentTextView: BaseView { + + weak var delegate: CommonQuestCommentProtcol? + + private let topBorderLine = UIView() + private let textFieldContainer = UIView() + private(set) var textView = UITextView() + private let countConfirmContainer = UIView() + private let textCountLabel = UILabel() + private let confirmButton = UIButton() + + private let placeholder: String = "댓글로 위로를 남겨보세요." + private var isPlaceholderActive: Bool = true + private var isEditing: Bool = false + private let textCountLimit: Int = 500 + + override init(frame: CGRect) { + super.init(frame: frame) + textView.delegate = self + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func setUI() { + addSubviews(topBorderLine,textFieldContainer, confirmButton) + textFieldContainer.addSubviews(textView) + } + + override func setStyle() { + backgroundColor = .grayscale900 + + topBorderLine.do { + $0.backgroundColor = .grayscale800 + } + + textFieldContainer.do { + $0.backgroundColor = .white5 + $0.layer.cornerRadius = 8 + } + + textView.do { + let offset = (FontManager.body6R14.lineHeight - FontManager.body6R14.font.lineHeight) / 2 + $0.backgroundColor = .clear + $0.text = placeholder + $0.font = FontManager.body6R14.font + $0.textColor = .grayscale600 + $0.textContainerInset = .init( + top: offset, + left: 0, + bottom: offset, + right: 0 + ) + $0.isScrollEnabled = false + $0.textContainer.maximumNumberOfLines = 1 + $0.textContainer.lineBreakMode = .byTruncatingTail + } + + textCountLabel.do { + $0.text = "0/\(textCountLimit)" + $0.applyByeBooFont(style: .cap2R12, color: .grayscale400) + } + + confirmButton.do { + $0.applyByeBooFont(style: .body2M16, text: "완료", color: .grayscale600) + $0.addTarget(self, action: #selector(confirmButtonDidTap), for: .touchUpInside) + } + } + + override func setLayout() { + textFieldContainer.snp.makeConstraints { + $0.top.equalToSuperview().inset(8.adjustedH) + $0.leading.equalToSuperview().inset(24.adjustedW) + $0.trailing.equalTo(confirmButton.snp.leading).offset(-20) + $0.height.equalTo(40.adjustedH) + $0.bottom.equalToSuperview().inset(6.adjustedH) + } + + topBorderLine.snp.makeConstraints { + $0.top.equalToSuperview() + $0.horizontalEdges.equalToSuperview() + $0.height.equalTo(1.adjustedH) + } + + textView.snp.makeConstraints { + $0.verticalEdges.equalToSuperview().inset(9.5.adjustedH) + $0.horizontalEdges.equalToSuperview().inset(12.adjustedW) + } + + confirmButton.snp.makeConstraints { + $0.top.equalToSuperview().inset(17.5.adjustedH) + $0.trailing.equalToSuperview().inset(24.adjustedW) + $0.height.equalTo(21.adjustedH) + } + } + +} + +extension CommentTextView: UITextViewDelegate { + func textViewDidBeginEditing(_ textView: UITextView) { + if isPlaceholderActive { + isPlaceholderActive = false + textView.text = "" + } + textView.applyTextViewStyle(style: .body6R14, text: textView.text, color: .grayscale100) + updateTextViewLayoutWhenEditing() + } + + func textViewDidChange(_ textView: UITextView) { + textCountLabel.text = "\(textView.text.count)/500" + let hasText = !textView.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + confirmButton.isEnabled = hasText + confirmButton.applyByeBooFont(style: .body2M16, text: "완료", color: hasText ? .primary300 : .grayscale600) + + if textView.text.count > textCountLimit { + textView.deleteBackward() + return + } + + let maxHeight = 105.adjustedH + let fittingHeight = textView.sizeThatFits(CGSize(width: textView.bounds.width, height: .infinity)).height + + let shouldScroll = fittingHeight > maxHeight + textView.isScrollEnabled = shouldScroll + if !shouldScroll { textView.contentOffset = .zero } + } + + func textViewDidEndEditing(_ textView: UITextView) { + isPlaceholderActive = textView.text.isEmpty + updateTextViewLayoutWhenEndEditing() + } +} + +extension CommentTextView { + func configureWhenEdit(content: String) { + textView.text = content + isPlaceholderActive = false + isEditing = true + } + + @objc + private func confirmButtonDidTap() { + if isEditing { + delegate?.editComment(content: textView.text) + } else { + delegate?.postComment(content: textView.text) + } + endEditing(true) + textView.text = placeholder + isPlaceholderActive = true + isEditing = false + textView.textColor = .grayscale600 + } +} + +extension CommentTextView { + private func updateTextViewLayoutWhenEditing() { + textView.textContainer.maximumNumberOfLines = 0 + textView.textContainer.lineBreakMode = .byWordWrapping + textFieldContainer.backgroundColor = .clear + confirmButton.removeFromSuperview() + addSubviews(countConfirmContainer) + countConfirmContainer.addSubviews(textCountLabel, confirmButton) + + textFieldContainer.snp.remakeConstraints { + $0.top.equalToSuperview().inset(8.adjustedH) + $0.horizontalEdges.equalToSuperview() + $0.bottom.equalTo(countConfirmContainer.snp.top) + } + + textView.snp.remakeConstraints { + $0.top.equalToSuperview().inset(9.adjustedH) + $0.horizontalEdges.equalToSuperview().inset(24.adjustedW) + $0.height.greaterThanOrEqualTo(42.adjustedH) + $0.height.lessThanOrEqualTo(105.adjustedH) + $0.bottom.equalToSuperview().inset(9.adjustedH) + } + + countConfirmContainer.snp.makeConstraints { + $0.leading.equalToSuperview() + $0.trailing.equalToSuperview().inset(24.adjustedW) + $0.bottom.equalToSuperview().inset(8.adjustedH) + $0.height.equalTo(40.adjustedH) + } + + textCountLabel.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.trailing.equalTo(confirmButton.snp.leading).offset(-12.adjustedW) + } + + confirmButton.snp.makeConstraints { + $0.centerY.equalToSuperview() + $0.trailing.equalToSuperview() + $0.height.equalTo(40.adjustedH) + } + } + + private func updateTextViewLayoutWhenEndEditing() { + textFieldContainer.backgroundColor = .white5 + countConfirmContainer.removeFromSuperview() + addSubviews(confirmButton) + + textFieldContainer.snp.remakeConstraints { + $0.top.equalToSuperview().inset(8.adjustedH) + $0.leading.equalToSuperview().inset(24.adjustedW) + $0.trailing.equalTo(confirmButton.snp.leading).offset(-20) + $0.height.equalTo(40.adjustedH) + $0.bottom.equalToSuperview().inset(6.adjustedH) + } + + textView.snp.remakeConstraints { + $0.verticalEdges.equalToSuperview().inset(9.5.adjustedH) + $0.horizontalEdges.equalToSuperview().inset(12.adjustedW) + } + + confirmButton.snp.makeConstraints { + $0.top.equalToSuperview().inset(17.5.adjustedH) + $0.trailing.equalToSuperview().inset(24.adjustedW) + $0.height.equalTo(21.adjustedH) + } + + textView.isScrollEnabled = false + textView.textContainer.maximumNumberOfLines = 1 + textView.textContainer.lineBreakMode = .byTruncatingTail + + let text = isPlaceholderActive ? placeholder : (textView.text ?? "") + let color: UIColor = isPlaceholderActive ? .grayscale600 : .grayscale100 + textView.typingAttributes = [.font: FontManager.body6R14.font, .foregroundColor: color] + textView.text = text + textView.textColor = color + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/View/CommonQuest/Common/QuestContentView.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/View/CommonQuest/Common/QuestContentView.swift new file mode 100644 index 00000000..97b71977 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/View/CommonQuest/Common/QuestContentView.swift @@ -0,0 +1,143 @@ +// +// QuestContentView.swift +// ByeBoo-iOS +// +// Created by 이나연 on 4/29/26. +// + +import UIKit + +protocol CommonQuestLikeProtocol: AnyObject { + func likeButtonDidTap(answerID: Int) +} + +final class QuestContentView: BaseView { + + private let answerContentTextView = UITextView() + private let writtenDateLabel = UILabel() + + private let likeCommentStackView = UIStackView() + private let likeContainerView = UIStackView() + private(set) var likeButton = UIButton() + private let likeCountLabel = UILabel() + private let commentContainerView = UIStackView() + private let commentIcon = UIImageView() + private let commentCountLabel = UILabel() + + weak var delegate: CommonQuestLikeProtocol? + + private var answerID: Int = 0 + private var likeCounts: Int = 0 + + override func setUI() { + self.addSubviews( + answerContentTextView, + writtenDateLabel, + likeCommentStackView + ) + likeCommentStackView.addArrangedSubviews(likeContainerView, commentContainerView) + likeContainerView.addArrangedSubviews(likeButton, likeCountLabel) + commentContainerView.addArrangedSubviews(commentIcon, commentCountLabel) + } + + override func setStyle() { + answerContentTextView.do { + $0.applyByeBooFont(style: .body3R16, color: .grayscale100) + $0.isScrollEnabled = false + $0.isEditable = false + $0.isSelectable = false + $0.isUserInteractionEnabled = false + $0.backgroundColor = .clear + $0.textContainer.lineBreakMode = .byTruncatingTail + } + writtenDateLabel.do { + $0.applyByeBooFont(style: .cap2R12, color: .grayscale400) + $0.isHidden = true + } + likeCommentStackView.do { + $0.axis = .horizontal + $0.spacing = 16.adjustedW + } + [likeContainerView, commentContainerView].forEach { + $0.do { + $0.axis = .horizontal + $0.spacing = 4.adjustedW + } + } + likeContainerView.do { + let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(likeButtonDidTap)) + $0.addGestureRecognizer(tapRecognizer) + $0.isUserInteractionEnabled = true + } + likeButton.do { + $0.setImage(.heartOff, for: .normal) + $0.setImage(.heartOn, for: .selected) + $0.isUserInteractionEnabled = false + } + commentIcon.do { + $0.image = .comment + } + [likeCountLabel, commentCountLabel].forEach { + $0.do { + $0.text = "0" + $0.applyByeBooFont(style: .cap2R12, color: .grayscale100) + } + } + } + + override func setLayout() { + answerContentTextView.snp.makeConstraints { + $0.top.equalToSuperview() + $0.horizontalEdges.equalToSuperview() + } + writtenDateLabel.snp.makeConstraints { + $0.top.equalTo(answerContentTextView.snp.bottom).offset(20.adjustedH) + $0.leading.equalToSuperview() + $0.height.equalTo(16.adjustedH) + } + likeCommentStackView.snp.makeConstraints { + $0.top.equalTo(answerContentTextView.snp.bottom).offset(20) + $0.trailing.equalToSuperview() + $0.bottom.equalToSuperview() + } + [likeButton, commentIcon].forEach { + $0.snp.makeConstraints { + $0.size.equalTo(20) + } + } + } + + func configure( + answerID: Int, + content: String, + writtenAt: String? = nil, + isLiked: Bool, + likeCount: Int, + commentCount: Int, + showAllText: Bool + ) { + self.answerID = answerID + self.likeCounts = likeCount + answerContentTextView.do { + $0.textContainer.maximumNumberOfLines = showAllText ? 0 : 2 + $0.applyTextViewStyle(style: .body3R16, text: content, color: .grayscale100) + } + if let writtenAt { + writtenDateLabel.isHidden = false + writtenDateLabel.text = writtenAt + } + likeButton.isSelected = isLiked + likeCountLabel.text = String(likeCount) + commentCountLabel.text = String(commentCount) + } + + func updateUI(likeCount: Int, isLiked: Bool) { + likeCountLabel.text = String(likeCount) + likeButton.isSelected = isLiked + } + + @objc + private func likeButtonDidTap() { + delegate?.likeButtonDidTap(answerID: self.answerID) + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/View/CommonQuest/CommonQuestHistoryView.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/View/CommonQuest/CommonQuestHistoryView.swift index 724b42c6..54b11367 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/View/CommonQuest/CommonQuestHistoryView.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/View/CommonQuest/CommonQuestHistoryView.swift @@ -7,12 +7,13 @@ import UIKit +import SnapKit + final class CommonQuestHistoryView: BaseView { + + private var commentTextViewBottomConstraint: Constraint? - private var profileIcon: UIImage? - private var nickname: String? - - private let scrollView = UIScrollView() + private(set) var scrollView = UIScrollView() private let contentView = UIView() private let commonQuestLabel = UILabel() private let dateLabel = UILabel() @@ -22,9 +23,14 @@ final class CommonQuestHistoryView: BaseView { private let answerView = UIView() private let profileIconImageView = UIImageView() private let userNicknameLabel = UILabel() - private let answerContentLabel = UILabel() + private(set) var questContentView = QuestContentView() + private(set) var commentListView = SelfSizingTableView() + private(set) var commentTextView = CommentTextView() override func setStyle() { + scrollView.do { + $0.showsVerticalScrollIndicator = false + } commonQuestLabel.applyByeBooFont( style: .body6R14, text: "공통퀘스트", @@ -51,44 +57,35 @@ final class CommonQuestHistoryView: BaseView { $0.layer.cornerRadius = 12 $0.backgroundColor = .white5 } + commentListView.do { + $0.isScrollEnabled = false + $0.rowHeight = UITableView.automaticDimension + $0.estimatedRowHeight = 100 + } userNicknameLabel.applyByeBooFont( style: .body6R14, color: .grayscale200 ) - answerContentLabel.do { - $0.applyByeBooFont( - style: .body3R16, - color: .grayscale100, - numberOfLines: 0 - ) - } } override func setUI() { - addSubview(scrollView) + addSubviews(scrollView, commentTextView) scrollView.addSubview(contentView) contentView.addSubviews( commonQuestLabel, dateLabel, questionView, - answerView + answerView, + commentListView ) questionView.addSubviews( questionMarkLabel, questionContentLabel ) - - guard let _ = profileIcon, - let _ = nickname - else { - answerView.addSubview(answerContentLabel) - return - } - answerView.addSubviews( profileIconImageView, userNicknameLabel, - answerContentLabel + questContentView ) } @@ -96,7 +93,7 @@ final class CommonQuestHistoryView: BaseView { scrollView.snp.makeConstraints { $0.top.equalTo(safeAreaLayoutGuide).offset(16.adjustedH) $0.horizontalEdges.equalToSuperview().inset(24.adjustedW) - $0.bottom.equalToSuperview().inset(24.adjustedH) + $0.bottom.equalTo(commentTextView.snp.top) } contentView.snp.makeConstraints { $0.edges.equalTo(scrollView.contentLayoutGuide) @@ -129,20 +126,7 @@ final class CommonQuestHistoryView: BaseView { answerView.snp.makeConstraints { $0.top.equalTo(questionView.snp.bottom).offset(30.adjustedH) $0.horizontalEdges.equalToSuperview() - $0.bottom.equalToSuperview().inset(24.adjustedH) } - - guard let _ = profileIcon, - let _ = nickname - else { - answerContentLabel.snp.makeConstraints { - $0.top.equalToSuperview().inset(16.adjustedH) - $0.horizontalEdges.equalToSuperview().inset(24.adjustedW) - $0.bottom.equalToSuperview().inset(16.adjustedH) - } - return - } - profileIconImageView.snp.makeConstraints { $0.top.equalToSuperview().inset(16.adjustedH) $0.leading.equalToSuperview().inset(24.adjustedW) @@ -153,44 +137,57 @@ final class CommonQuestHistoryView: BaseView { $0.centerY.equalTo(profileIconImageView) $0.trailing.equalToSuperview().inset(24.adjustedW) } - answerContentLabel.snp.makeConstraints { + questContentView.snp.makeConstraints { $0.top.equalTo(profileIconImageView.snp.bottom).offset(12.adjustedH) $0.horizontalEdges.equalToSuperview().inset(24.adjustedW) $0.bottom.equalToSuperview().inset(16.adjustedH) } + commentListView.snp.makeConstraints { + $0.top.equalTo(answerView.snp.bottom).offset(24.adjustedH) + $0.horizontalEdges.equalToSuperview() + $0.bottom.equalToSuperview().inset(24.adjustedH) + } + commentTextView.snp.makeConstraints { + $0.horizontalEdges.equalToSuperview() + commentTextViewBottomConstraint = $0.bottom.equalToSuperview().inset(34.adjustedH).constraint + } } } extension CommonQuestHistoryView { - + + func updateLayout(keyboardHeight: CGFloat) { + let inset = keyboardHeight > 0 ? keyboardHeight : 34.adjustedH + commentTextViewBottomConstraint?.update(inset: inset) + } +} + +extension CommonQuestHistoryView { + func configure( + answerID: Int, question: String, writtenAt: String, - profileIcon: UIImage?, - nickname: String?, - content: String + profileIcon: UIImage, + nickname: String, + content: String, + isLiked: Bool, + likeCount: Int, + commentCount: Int ) { questionContentLabel.text = question dateLabel.text = writtenAt - answerContentLabel.text = content - - answerContentLabel.do { - $0.applyByeBooFont( - style: .body3R16, - text: content, - color: .grayscale100, - numberOfLines: 0 - ) - } - - if let profileIcon { - self.profileIcon = profileIcon - profileIconImageView.image = profileIcon - } + questContentView.configure( + answerID: answerID, + content: content, + isLiked: isLiked, + likeCount: likeCount, + commentCount: commentCount, + showAllText: true + ) + + profileIconImageView.image = profileIcon + userNicknameLabel.text = nickname - if let nickname { - self.nickname = nickname - userNicknameLabel.text = nickname - } } } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/View/CommonQuest/CommonQuestReplyView.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/View/CommonQuest/CommonQuestReplyView.swift new file mode 100644 index 00000000..5d1a9d56 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/View/CommonQuest/CommonQuestReplyView.swift @@ -0,0 +1,76 @@ +// +// CommonQuestReplyView.swift +// ByeBoo-iOS +// +// Created by 이나연 on 5/8/26. +// + +import UIKit + +import SnapKit + +final class CommonQuestReplyView: BaseView { + private let headerView = UIView() + private(set) var backButton = UIButton() + private let headerLabel = UILabel() + private(set) var commentListView = UITableView() + private(set) var commentTextView = CommentTextView() + + private var commentTextViewBottomConstraint: Constraint? + + override func setUI() { + addSubviews(headerView, commentListView, commentTextView) + headerView.addSubviews(backButton, headerLabel) + } + + override func setStyle() { + backgroundColor = .grayscale900 + + backButton.do { + $0.setImage(.left.withRenderingMode(.alwaysTemplate), for: .normal) + $0.tintColor = .white + } + + headerLabel.do { + $0.applyByeBooFont(style: .sub1Sb20, text: "답글", color: .white) + } + + commentListView.do { + $0.backgroundColor = .grayscale900 + $0.showsVerticalScrollIndicator = false + } + } + + override func setLayout() { + headerView.snp.makeConstraints { + $0.top.equalToSuperview().inset(26.adjustedH) + $0.horizontalEdges.equalToSuperview() + $0.height.equalTo(40.adjustedH) + } + backButton.snp.makeConstraints { + $0.top.equalToSuperview().inset(8.adjustedH) + $0.leading.equalToSuperview().inset(24.adjustedH) + $0.size.equalTo(24.adjustedH) + } + headerLabel.snp.makeConstraints { + $0.top.equalToSuperview().inset(8.adjustedH) + $0.centerX.equalToSuperview() + } + commentListView.snp.makeConstraints { + $0.top.equalTo(headerView.snp.bottom).offset(6.adjustedH) + $0.horizontalEdges.equalToSuperview().inset(20.adjustedW) + $0.bottom.equalTo(commentTextView.snp.top) + } + commentTextView.snp.makeConstraints { + $0.horizontalEdges.equalToSuperview() + commentTextViewBottomConstraint = $0.bottom.equalToSuperview().inset(34.adjustedH).constraint + } + } +} + +extension CommonQuestReplyView { + func updateLayout(keyboardHeight: CGFloat) { + let inset = keyboardHeight > 0 ? keyboardHeight : 34.adjustedH + commentTextViewBottomConstraint?.update(inset: inset) + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/View/QuestMain/QuestEmptyView.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/View/QuestMain/QuestEmptyView.swift new file mode 100644 index 00000000..cc59026f --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/View/QuestMain/QuestEmptyView.swift @@ -0,0 +1,57 @@ +// +// QuestEmptyView.swift +// ByeBoo-iOS +// +// Created by 최주리 on 5/5/26. +// + +import UIKit + +final class QuestEmptyView: BaseView { + + private let stackView = UIStackView() + private let imageView = UIImageView() + private let textLabel = UILabel() + let button = ByeBooButton(titleText: "보리 만나러 가기", type: .enabled) + + override func setStyle() { + stackView.do { + $0.axis = .vertical + $0.spacing = 20 + $0.alignment = .center + } + + imageView.do { + $0.image = .talk + } + + textLabel.do { + $0.text = "하츠핑님, 수고하셨어요.\n보리가 하고 싶은 말이 있다고 해요!" + $0.applyByeBooFont(style: .sub3M18, color: .grayscale50) + $0.numberOfLines = 2 + $0.textAlignment = .center + } + } + + override func setUI() { + addSubview(stackView) + stackView.addArrangedSubviews(imageView, textLabel, button) + } + + override func setLayout() { + stackView.snp.makeConstraints { + $0.centerX.equalToSuperview() + $0.top.equalToSuperview().inset(176.adjustedH) + } + + button.snp.makeConstraints { + $0.width.equalToSuperview() + } + } +} + +extension QuestEmptyView { + func configure(name: String) { + textLabel.text = "\(name)님, 수고하셨어요.\n보리가 하고 싶은 말이 있다고 해요!" + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/View/Write/QuestTextField.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/View/Write/QuestTextField.swift index 7b5e00dc..591dfd99 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/View/Write/QuestTextField.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/View/Write/QuestTextField.swift @@ -77,17 +77,17 @@ extension QuestTextField: UITextViewDelegate { func textViewDidBeginEditing(_ textView: UITextView) { if isPlaceholderActive == true { isPlaceholderActive = false - applyTextViewStyle(text: "", color: .grayscale100) + textView.applyTextViewStyle(style: .body3R16 ,text: "", color: .grayscale100) } textView.textColor = .grayscale100 } func textViewDidEndEditing(_ textView: UITextView) { if textView.text.isEmpty { - applyTextViewStyle(text: placeholder, color: .grayscale300) + textView.applyTextViewStyle(style: .body3R16 ,text: placeholder, color: .grayscale300) isPlaceholderActive = true } else { - applyTextViewStyle(text: textView.text, color: .grayscale100) + textView.applyTextViewStyle(style: .body3R16, text: textView.text, color: .grayscale100) } questTextViewDelegate?.textViewDidEndEditing() } @@ -102,14 +102,6 @@ extension QuestTextField: UITextViewDelegate { } extension QuestTextField { - func applyTextViewStyle(text: String, color: UIColor) { - textView.applyByeBooFont( - style: .body3R16, - text: text, - color: color - ) - } - func updateTextViewHeight() -> CGFloat { let width = self.frame.width let fittingSize = CGSize(width: width, height: .greatestFiniteMagnitude) diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewController/AIAnswerViewController.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewController/AIAnswerViewController.swift index 1e7843ec..50fda3ed 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewController/AIAnswerViewController.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewController/AIAnswerViewController.swift @@ -50,6 +50,12 @@ final class AIAnswerViewController: BaseViewController { ) ) } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + viewModel.action(.viewDidDisappear) + } } extension AIAnswerViewController { diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewController/CommonQuestHistoryViewController.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewController/CommonQuestHistoryViewController.swift index 7c80aab0..aa56c93d 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewController/CommonQuestHistoryViewController.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewController/CommonQuestHistoryViewController.swift @@ -5,19 +5,31 @@ // Created by APPLE on 2/24/26. // +import Combine import UIKit import Mixpanel final class CommonQuestHistoryViewController: BaseViewController { + private var dataSource: UITableViewDiffableDataSource! + private var cancellable = Set() + private let rootView = CommonQuestHistoryView() - private var answerID: Int? - private var answer: String? - private var question: String? - private var writtenAt: String? - private var commonQuestArchiveType: CommonQuestArchiveType = .mine + private let viewModel: CommonQuestHistoryViewModel + + init(viewModel: CommonQuestHistoryViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private var answerID: Int = 0 private var writerID: Int = 0 + private var editingCommentID: Int? override func loadView() { view = rootView @@ -26,6 +38,9 @@ final class CommonQuestHistoryViewController: BaseViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) self.tabBarController?.tabBar.isHidden = true + addKeyboardObservers() + + viewModel.action(.fetchQuestDetail(answerID: answerID)) Mixpanel.mainInstance().track(event: CommonJourneyEvents.Name.commonJourneyOthersAnswerPageview) } @@ -40,19 +55,83 @@ final class CommonQuestHistoryViewController: BaseViewController { action: #selector(back), secondAction: #selector(bottomUp) ) + + bind() + + configureDataSource() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + removeKeyboardObservers() + } + + override func setAddTarget() { + let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard)) + tapGestureRecognizer.cancelsTouchesInView = false + rootView.scrollView.addGestureRecognizer(tapGestureRecognizer) + } + + override func setDelegate() { + rootView.commentListView.do { + $0.separatorStyle = .none + $0.register(CommentTableViewCell.self) + } + rootView.questContentView.delegate = self + rootView.commentTextView.delegate = self + } +} + +extension CommonQuestHistoryViewController { + func configure(answerID: Int) { + self.answerID = answerID } } extension CommonQuestHistoryViewController: BackNavigable { - func back() { self.navigationController?.popViewController(animated: false) } } -extension CommonQuestHistoryViewController: CommonQuestBottomSheetDelegate { +extension CommonQuestHistoryViewController { + private func configureDataSource() { + dataSource = UITableViewDiffableDataSource( + tableView: rootView.commentListView + ) { tableView, indexPath, item in + guard let cell = tableView.dequeueReusableCell( + withIdentifier: CommentTableViewCell.identifier, + for: indexPath + ) as? CommentTableViewCell else { return UITableViewCell() } + + let entity = item.entity + cell.configure( + isMyComment: entity.isMyComment, + commentID: entity.commentID, + replyCount: entity.replyCount, + writer: entity.writer, + profileIcon: ProfileIcon.image(for: entity.profileIcon) ?? .relievedBadge, + writtenAt: ServerDateFormatter.shared.relativeTimeString(from: entity.writtenAt) ?? "", + content: entity.content, + showAllText: item.showAllText, + isReplySheet: false + ) + cell.delegate = self + return cell + } + } - func didTapEdit( + private func applySnapshot(commentList: [CommonQuestCommentEntity]) { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + let items = commentList.map { CommentItem(entity: $0, showAllText: false) } + snapshot.appendItems(items, toSection: .main) + dataSource.apply(snapshot, animatingDifferences: false) + } +} + +extension CommonQuestHistoryViewController: EditCommonQuestProtocol { + func didTapCommonQuestEdit( answerID: Int, answer: String, question: String, @@ -62,36 +141,30 @@ extension CommonQuestHistoryViewController: CommonQuestBottomSheetDelegate { writeCommonQuestViewController.navigationItem.hidesBackButton = true writeCommonQuestViewController.questScope = .common writeCommonQuestViewController.configureToEdit( - nil, .question, question, answerID, answer, writtenAt + questNumber: nil, + questType: .question, + questionTitle: question, + answerID: answerID, + answer: answer, + writtenAt: writtenAt ) self.navigationController?.pushViewController(writeCommonQuestViewController, animated: false) } + func didTapCommentEdit(commentID: Int, content: String) { + editingCommentID = commentID + rootView.commentTextView.configureWhenEdit(content: content) + rootView.commentTextView.textView.becomeFirstResponder() + } + @objc private func bottomUp() { - let commonQuestBottomSheet = ViewControllerFactory.shared.makeCommonQuestBottomSheetViewController() - commonQuestBottomSheet.configure(sheeetType: commonQuestArchiveType, writerID: writerID) - commonQuestBottomSheet.configure( - sheeetType: commonQuestArchiveType, - answerID: answerID, - answer: answer, - question: question, - writtenAt: writtenAt - ) - setDelegate(bottomSheet: commonQuestBottomSheet) - - if let sheet = commonQuestBottomSheet.sheetPresentationController{ - sheet.detents = [.custom { _ in 224.adjustedH }] - sheet.prefersGrabberVisible = true - sheet.prefersScrollingExpandsWhenScrolledToEdge = false - sheet.preferredCornerRadius = 8 - } - self.present(commonQuestBottomSheet, animated: true) + commonBottomSheetUp() } private func setDelegate(bottomSheet: CommonQuestBottomSheetViewController) { bottomSheet.do { - $0.bottomDelegate = self + $0.editDelegate = self $0.deleteDelegate = self $0.blockDelegate = self } @@ -99,43 +172,58 @@ extension CommonQuestHistoryViewController: CommonQuestBottomSheetDelegate { } extension CommonQuestHistoryViewController: DeleteCommonQuestDelegate { - func completeDeleteCommonQuest() { - self.navigationController?.popViewController(animated: false) + func completeDeleteCommonQuest(deletedID: Int) { + ByeBooLogger.debug("댓글 삭제 완료") + viewModel.action(.fetchQuestDetail(answerID: answerID)) } } -extension CommonQuestHistoryViewController { +extension CommonQuestHistoryViewController: CommentProtocol { + func moreLabelDidTap(commentID: Int) { + let updatedItems = dataSource.snapshot().itemIdentifiers.map { item -> CommentItem in + guard item.entity.commentID == commentID else { return item } + return CommentItem(entity: item.entity, showAllText: true) + } + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(updatedItems, toSection: .main) + dataSource.apply(snapshot, animatingDifferences: false) { [weak self] in + self?.rootView.commentListView.performBatchUpdates(nil) + } + } - func configure( - question: String, - writtenAt: String, - profileIcon: UIImage? = nil, - nickname: String? = nil, - content: String, - answerID: Int? = nil, - writerID: Int? = nil, - isMyAnswer: Bool? = nil - ) { - self.answerID = answerID - self.answer = content - self.question = question - self.writtenAt = writtenAt - - if let isMyAnswer { - commonQuestArchiveType = isMyAnswer ? .mine : .other + func replyIconDidTap(commentID: Int) { + let viewController = ViewControllerFactory.shared.makeCommonQuestReplyViewController() + let entity = viewModel.getComment(commentID: commentID) + guard let entity else { return } + viewController.configure(entity: entity, commentID: commentID) + + viewController.onReplyCountChanged = { [weak self] commentID, newCount in + guard let self else { return } + let item = dataSource.snapshot().itemIdentifiers.first { $0.entity.commentID == commentID } + guard let item, + let indexPath = dataSource.indexPath(for: item), + let cell = rootView.commentListView.cellForRow(at: indexPath) as? CommentTableViewCell + else { return } + cell.updateReplyCount(replyCount: newCount) } - if let writerID { - self.writerID = writerID + viewController.onDismiss = { [weak self] in + guard let self else { return } + viewModel.action(.fetchQuestDetail(answerID: answerID)) } - rootView.configure( - question: question, - writtenAt: writtenAt, - profileIcon: profileIcon, - nickname: nickname, - content: content - ) + if let sheet = viewController.sheetPresentationController{ + sheet.detents = [.large()] + sheet.prefersGrabberVisible = true + sheet.prefersScrollingExpandsWhenScrolledToEdge = false + } + self.present(viewController, animated: true) + } + + func menuButtonDidTap(commentID: Int, isMyComment: Bool) { + commonBottomSheetUp(commentID: commentID, isMyComment: isMyComment) } } @@ -150,3 +238,176 @@ extension CommonQuestHistoryViewController: BlockReportProtocol { } } } + +extension CommonQuestHistoryViewController: CommonQuestCommentProtcol { + func postComment(content: String) { + viewModel.action(.postComment(answerID: answerID, content: content)) + } + + func editComment(content: String) { + guard let editingCommentID else { return } + viewModel.action(.patchComment(answerID: answerID, commentID: editingCommentID , content: content)) + } +} + +extension CommonQuestHistoryViewController { + @objc + private func dismissKeyboard() { + view.endEditing(true) + } + + private func bind() { + bindCommonQuestDetail() + bindCommonQuestLikeCount() + bindPostComment() + bindPatchComment() + } + + private func bindData(entity: CommonQuestDetailEntity) { + let answer = entity.answer + self.writerID = answer.writerID + + rootView.configure( + answerID: answerID, + question: entity.question, + writtenAt: ServerDateFormatter.shared.relativeTimeString(from: answer.writtenAt) ?? "", //TODO: ViewModel로 수정 + profileIcon: ProfileIcon.image(for: answer.profileIcon) ?? .relievedBadge, + nickname: answer.writer, + content: answer.content, + isLiked: answer.isLiked, + likeCount: answer.likeCount, + commentCount: answer.commentCount + ) + } + + private func bindCommonQuestDetail() { + viewModel.output.fetchCommonQuestDetailPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] result in + switch result { + case .success(let entity): + self?.bindData(entity: entity) + self?.applySnapshot(commentList: entity.comments) + case .failure(let error): + ByeBooLogger.debug(error) + } + } + .store(in: &cancellable) + } + + private func bindCommonQuestLikeCount() { + viewModel.output.commonQuestLikeCountPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] result in + switch result { + case .success(let result): + let entity = result.entity + self?.rootView.questContentView.updateUI(likeCount: entity.likeCount, isLiked: entity.isLiked) + case .failure(let error): + ByeBooLogger.error(error) + } + } + .store(in: &cancellable) + } + + private func bindPostComment() { + viewModel.output.postCommentPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] result in + guard let self else { return } + switch result { + case .success: + ByeBooLogger.debug("댓글 입력 성공") + self.scrollToComment() + case .failure(let error): + ByeBooLogger.debug(error) + } + } + .store(in: &cancellable) + } + + private func bindPatchComment() { + viewModel.output.patchCommentPublisher + .receive(on: DispatchQueue.main) + .sink { result in + switch result { + case .success: + ByeBooLogger.debug("댓글 수정 성공") + case .failure(let error): + ByeBooLogger.debug(error) + } + } + .store(in: &cancellable) + } +} + +extension CommonQuestHistoryViewController { + private func commonBottomSheetUp(commentID: Int? = nil, isMyComment: Bool? = nil) { + let commonQuestBottomSheet = ViewControllerFactory.shared.makeCommonQuestBottomSheetViewController() + + if let commentID, let isMyComment{ + configureWhenComment(commentID: commentID, isMyComment: isMyComment, sheet: commonQuestBottomSheet) + } else { + configureWhenCommonQuest(sheet: commonQuestBottomSheet) + } + + setDelegate(bottomSheet: commonQuestBottomSheet) + presentBottomSheet(commonQuestBottomSheet, height: 224.adjustedH) + } + + private func configureWhenComment( + commentID: Int, + isMyComment: Bool, + sheet: CommonQuestBottomSheetViewController + ) { + guard let comment = viewModel.getComment(commentID: commentID) else { return } + sheet.configure( + sheetType: isMyComment ? .myComment : .otherComment , + targetID: commentID, + writerID: comment.writerID, + content: comment.content + ) + } + + private func configureWhenCommonQuest(sheet: CommonQuestBottomSheetViewController) { + let entity = viewModel.detailEntity + guard let entity else { return } + + let sheetType: CommonQuestArchiveType = entity.isMyAnswer ? .myAnswer : .otherAnswer + sheet.configure( + sheetType: sheetType, + targetID: answerID, + writerID: writerID, + answer: entity.isMyAnswer ? entity.content : nil, + question: entity.isMyAnswer ? viewModel.question : nil, + writtenAt: entity.isMyAnswer ? entity.writtenAt : nil + ) + } + + private func scrollToComment() { + let snapshot = dataSource.snapshot() + guard snapshot.numberOfItems > 0 else { return } + rootView.layoutIfNeeded() + + let lastIndex = IndexPath(row: snapshot.numberOfItems - 1, section: 0) + let rowRect = rootView.commentListView.rectForRow(at: lastIndex) + var rectInScrollView = rootView.commentListView.convert(rowRect, to: rootView.scrollView) + rectInScrollView.size.height = rootView.scrollView.contentSize.height - rectInScrollView.origin.y + + rootView.scrollView.scrollRectToVisible(rectInScrollView, animated: true) + } +} +extension CommonQuestHistoryViewController: KeyboardHandleProtocol { + func keyboardWillShowOrHide(height: CGFloat, duration: Double) { + UIView.animate(withDuration: duration) { + self.rootView.updateLayout(keyboardHeight: height) + self.rootView.layoutIfNeeded() + } + } +} + +extension CommonQuestHistoryViewController: CommonQuestLikeProtocol { + func likeButtonDidTap(answerID: Int) { + viewModel.action(.likeButtonDidTap(answerID: answerID)) + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewController/CommonQuestMyAnswersViewController.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewController/CommonQuestMyAnswersViewController.swift index 97473a87..67697919 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewController/CommonQuestMyAnswersViewController.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewController/CommonQuestMyAnswersViewController.swift @@ -69,6 +69,7 @@ extension CommonQuestMyAnswersViewController { private func bind() { bindName() bindCommonQuestAnswers() + bindLikeCount() } private func bindName() { @@ -98,6 +99,38 @@ extension CommonQuestMyAnswersViewController { } .store(in: &cancellable) } + + private func bindLikeCount() { + viewModel.output.commonQuestLikeCountPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] result in + switch result { + case .success(let result): + let entity = result.entity + self?.updateLikeCount( + answerID: result.answerID, + likeCount: entity.likeCount, + isLiked: entity.isLiked + ) + case .failure(let error): + ByeBooLogger.error(error) + } + } + .store(in: &cancellable) + } + + private func updateLikeCount(answerID: Int, likeCount: Int, isLiked: Bool) { + guard let answerIndex = viewModel.indexOfAnswer(answerID: answerID) else { + return + } + + let indexPath = IndexPath(row: 0, section: answerIndex) + guard let cell = rootView.answersTableView.cellForRow(at: indexPath) as? CommonQuestMyAnswerCell else { + return + } + + cell.questContentView.updateUI(likeCount: likeCount, isLiked: isLiked) + } } extension CommonQuestMyAnswersViewController: UITableViewDelegate { @@ -112,12 +145,7 @@ extension CommonQuestMyAnswersViewController: UITableViewDelegate { let historyViewController = ViewControllerFactory.shared.makeCommonQuestHistoryViewController() historyViewController.navigationItem.hidesBackButton = true - historyViewController.configure( - question: answer.question, - writtenAt: answer.writtenAt, - content: answer.content, - answerID: answer.answerID - ) + historyViewController.configure(answerID: answer.answerID) self.navigationController?.pushViewController(historyViewController, animated: false) } @@ -194,12 +222,22 @@ extension CommonQuestMyAnswersViewController: UITableViewDataSource { } let cell: CommonQuestMyAnswerCell = tableView.dequeueReusableCell(for: indexPath) - + cell.questContentView.delegate = self cell.bind( + answerID: answer.answerID, question: answer.question, content: answer.content, - writtenAt: answer.writtenAt + writtenAt: answer.writtenAt, + isLiked: answer.isLiked, + likeCount: answer.likeCount, + commentCount: answer.commentCount ) return cell } } + +extension CommonQuestMyAnswersViewController: CommonQuestLikeProtocol { + func likeButtonDidTap(answerID: Int) { + viewModel.action(.likeButtonDidTap(answerID: answerID)) + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewController/CommonQuestReplyViewController.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewController/CommonQuestReplyViewController.swift new file mode 100644 index 00000000..f852d994 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewController/CommonQuestReplyViewController.swift @@ -0,0 +1,314 @@ +// +// CommonQuestReplyViewController.swift +// ByeBoo-iOS +// +// Created by 이나연 on 5/8/26. +// + +import Combine +import UIKit + +final class CommonQuestReplyViewController: BaseViewController { + + private let rootView = CommonQuestReplyView() + private let viewModel: CommonQuestReplyViewModel + + private var commentEntity: CommonQuestCommentEntity? = nil + private var commentID: Int = 0 + private var editingCommentID: Int? + + var onReplyCountChanged: ((Int, Int) -> Void)? + + private var dataSource: UITableViewDiffableDataSource! + private var cancellable = Set() + + var onDismiss: (() -> Void)? + + init(viewModel: CommonQuestReplyViewModel) { + 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) + self.tabBarController?.tabBar.isHidden = true + addKeyboardObservers() + + viewModel.action(.fetchReplyList(commentID: commentID)) + } + + override func viewDidLoad() { + super.viewDidLoad() + + configureDataSource() + bind() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + removeKeyboardObservers() + } + + override func setAddTarget() { + rootView.backButton.addTarget(self, action: #selector(backButtonDidTap), for: .touchUpInside) + } + + override func setDelegate() { + rootView.commentListView.do { + $0.separatorStyle = .none + $0.register(CommentTableViewCell.self, forCellReuseIdentifier: "comment") + $0.register(CommentTableViewCell.self, forCellReuseIdentifier: "reply") + } + rootView.commentTextView.delegate = self + } +} + +extension CommonQuestReplyViewController { + func configure( + entity: CommonQuestCommentEntity, + commentID: Int + ) { + self.commentEntity = entity + self.commentID = commentID + viewModel.setComment(entity) + } + + private func bind() { + viewModel.output.fetchReplyListPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] result in + switch result { + case .success(let replyList): + if let cell = self?.rootView.commentListView.cellForRow(at: IndexPath(row: 0, section: 0)) as? CommentTableViewCell { + cell.updateReplyCount(replyCount: replyList.totalCount) + } + self?.applySnapshot(entity: replyList.replies) + self?.onReplyCountChanged?(self?.commentID ?? 0, replyList.totalCount) + case .failure(let error): + ByeBooLogger.debug(error) + } + } + .store(in: &cancellable) + + viewModel.output.postReplyPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] result in + guard let self else { return } + switch result { + case .success: + ByeBooLogger.debug("답글 입력 성공") + self.scrollToComment() + case .failure(let error): + ByeBooLogger.debug(error) + } + } + .store(in: &cancellable) + + viewModel.output.patchReplyPublisher + .receive(on: DispatchQueue.main) + .sink { result in + switch result { + case .success: + ByeBooLogger.debug("답글 수정 성공") + case .failure(let error): + ByeBooLogger.debug(error) + } + } + .store(in: &cancellable) + + } +} + +extension CommonQuestReplyViewController { + private func configureDataSource() { + dataSource = UITableViewDiffableDataSource( + tableView: rootView.commentListView + ) { tableView, indexPath, item in + let identifier = item.entity.replyCount != nil ? "comment" : "reply" + guard let cell = tableView.dequeueReusableCell( + withIdentifier: identifier, + for: indexPath + ) as? CommentTableViewCell else { return UITableViewCell() } + + let entity = item.entity + cell.configure( + isMyComment: entity.isMyComment, + commentID: entity.commentID, + replyCount: entity.replyCount, + writer: entity.writer, + profileIcon: ProfileIcon.image(for: entity.profileIcon) ?? .relievedBadge, + writtenAt: ServerDateFormatter.shared.relativeTimeString(from: entity.writtenAt) ?? "", + content: entity.content, + showAllText: item.showAllText, + isReplySheet: true + ) + cell.delegate = self + return cell + } + } + + private func applySnapshot(entity: [CommonQuestCommentEntity]) { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.comment, .replies]) + + guard let commentEntity else { return } + let commentItem = CommentItem(entity: commentEntity, showAllText: false) + snapshot.appendItems([commentItem], toSection: .comment) + + let replyItems = entity.map { CommentItem(entity: $0, showAllText: false) } + snapshot.appendItems(replyItems, toSection: .replies) + + dataSource.apply(snapshot, animatingDifferences: false) + } + + private func commonBottomSheetUp(commentID: Int, isMyComment: Bool) { + let commonQuestBottomSheet = ViewControllerFactory.shared.makeCommonQuestBottomSheetViewController() + + guard let comment = viewModel.getComment(commentID: commentID) else { return } + commonQuestBottomSheet.configure( + sheetType: isMyComment ? .myComment : .otherComment , + targetID: commentID, + writerID: comment.writerID, + content: comment.content + ) + + setDelegate(bottomSheet: commonQuestBottomSheet) + presentBottomSheet(commonQuestBottomSheet, height: 224.adjustedH) + } + + @objc + private func backButtonDidTap() { + self.dismiss(animated: true) + onDismiss?() + } + + private func scrollToComment() { + let snapshot = dataSource.snapshot() + let replyCount = snapshot.numberOfItems(inSection: .replies) + guard replyCount > 0 else { return } + rootView.layoutIfNeeded() + + let lastIndex = IndexPath(row: replyCount - 1, section: 1) + rootView.commentListView.scrollToRow(at: lastIndex, at: .bottom, animated: true) + } +} + +extension CommonQuestReplyViewController: CommentProtocol { + func menuButtonDidTap(commentID: Int, isMyComment: Bool) { + commonBottomSheetUp(commentID: commentID, isMyComment: isMyComment) + } + + func moreLabelDidTap(commentID: Int) { + let currentSnapshot = dataSource.snapshot() + + let commentItems = currentSnapshot.itemIdentifiers(inSection: .comment).map { item -> CommentItem in + guard item.entity.commentID == commentID else { return item } + return CommentItem(entity: item.entity, showAllText: true) + } + let replyItems = currentSnapshot.itemIdentifiers(inSection: .replies).map { item -> CommentItem in + guard item.entity.commentID == commentID else { return item } + return CommentItem(entity: item.entity, showAllText: true) + } + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.comment, .replies]) + snapshot.appendItems(commentItems, toSection: .comment) + snapshot.appendItems(replyItems, toSection: .replies) + dataSource.apply(snapshot, animatingDifferences: false) { [weak self] in + self?.rootView.commentListView.performBatchUpdates(nil) + } + } +} + +extension CommonQuestReplyViewController: EditCommonQuestProtocol { + func didTapCommonQuestEdit( + answerID: Int, + answer: String, + question: String, + writtenAt: String + ) { + let writeCommonQuestViewController = ViewControllerFactory.shared.makeWriteQuestionTypeQuestViewController() + writeCommonQuestViewController.navigationItem.hidesBackButton = true + writeCommonQuestViewController.questScope = .common + writeCommonQuestViewController.configureToEdit( + questNumber: nil, + questType: .question, + questionTitle: question, + answerID: answerID, + answer: answer, + writtenAt: writtenAt + ) + self.navigationController?.pushViewController(writeCommonQuestViewController, animated: false) + } + + func didTapCommentEdit(commentID: Int, content: String) { + editingCommentID = commentID + rootView.commentTextView.configureWhenEdit(content: content) + rootView.commentTextView.textView.becomeFirstResponder() + } + + @objc + private func bottomUp() { + guard let commentEntity else { return } + commonBottomSheetUp(commentID: commentID, isMyComment: commentEntity.isMyComment) + } + + private func setDelegate(bottomSheet: CommonQuestBottomSheetViewController) { + bottomSheet.do { + $0.editDelegate = self + $0.deleteDelegate = self + $0.blockDelegate = self + } + } +} + +extension CommonQuestReplyViewController: CommonQuestCommentProtcol { + func postComment(content: String) { + viewModel.action(.postReply(commentID: commentID, content: content)) + } + + func editComment(content: String) { + guard let editingCommentID else { return } + viewModel.action(.editReply(commentID: commentID, editingCommentID: editingCommentID, content: content)) + } +} + +extension CommonQuestReplyViewController: DeleteCommonQuestDelegate { + func completeDeleteCommonQuest(deletedID: Int) { + if deletedID == self.commentID { + dismiss(animated: true) + onDismiss?() + } else { + viewModel.action(.fetchReplyList(commentID: commentID)) + } + } +} + +extension CommonQuestReplyViewController: BlockReportProtocol { + func completeBlockReport(type: CommonQuestArchiveType.Action) { + ViewControllerUtils.changeQuestTabWithIndex(index: 1) { + NotificationCenter.default.post( + name: .showToastMessage, + object: nil, + userInfo: ["type": type] + ) + } + } +} + +extension CommonQuestReplyViewController: KeyboardHandleProtocol { + func keyboardWillShowOrHide(height: CGFloat, duration: Double) { + UIView.animate(withDuration: duration) { + self.rootView.updateLayout(keyboardHeight: height) + self.rootView.layoutIfNeeded() + } + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewController/CommonQuestViewController.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewController/CommonQuestViewController.swift index 924c64bd..5c322839 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewController/CommonQuestViewController.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewController/CommonQuestViewController.swift @@ -86,6 +86,32 @@ extension CommonQuestViewController { } } .store(in: &cancellable) + + viewModel.output.commonQuestLikeCountPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] result in + switch result { + case .success(let result): + let entity = result.entity + self?.updateLikeCount(answerID: result.answerID, likeCount: entity.likeCount, isLiked: entity.isLiked) + case .failure(let error): + ByeBooLogger.error(error) + } + } + .store(in: &cancellable) + } + + private func updateLikeCount(answerID: Int, likeCount: Int, isLiked: Bool) { + guard let answerIndex = viewModel.indexOfAnswer(answerID: answerID) else { + return + } + + let indexPath = IndexPath(row: answerIndex + 1, section: 0) + guard let cell = rootView.commonQuestTableView.cellForRow(at: indexPath) as? CommonQuestAnswerCell else { + return + } + + cell.questContentView.updateUI(likeCount: likeCount, isLiked: isLiked) } } @@ -129,29 +155,12 @@ extension CommonQuestViewController: UITableViewDelegate { } let answerIndex = indexPath.row - 1 - guard let answer = viewModel.getAnswer(at: answerIndex) else { - return - } - - let formattedWrittenAt = DateFormatter.toDetailDate(from: answer.writtenAt).map { - DateFormatter.toDisplayDateString(from: $0) - } - - guard let formattedWrittenAt else { + guard let answerID = viewModel.getAnswerID(at: answerIndex) else { return } let historyViewController = ViewControllerFactory.shared.makeCommonQuestHistoryViewController() - historyViewController.configure( - question: viewModel.question, - writtenAt: formattedWrittenAt, - profileIcon: viewModel.getProfileIcon(at: answerIndex), - nickname: answer.writer, - content: answer.content, - answerID: answer.answerID, - writerID: answer.writerID, - isMyAnswer: answer.isMyAnswer - ) + historyViewController.configure(answerID: answerID) historyViewController.navigationItem.hidesBackButton = true self.navigationController?.pushViewController( historyViewController, @@ -163,7 +172,7 @@ extension CommonQuestViewController: UITableViewDelegate { _ tableView: UITableView, heightForRowAt indexPath: IndexPath ) -> CGFloat { - indexPath.row == 0 ? UITableView.automaticDimension : 171.adjustedH + UITableView.automaticDimension } func tableView( @@ -260,7 +269,7 @@ extension CommonQuestViewController: UITableViewDataSource { let answer = viewModel.getAnswer(at: indexPath.row - 1) let profileIcon = viewModel.getProfileIcon(at: indexPath.row - 1) let writtenAt = viewModel.getWrittenAt(at: indexPath.row - 1) - + cell.questContentView.delegate = self if let answer, let writtenAt { cell.bind( @@ -281,3 +290,10 @@ extension CommonQuestViewController: UITableViewDataSource { return cell } } + +extension CommonQuestViewController: CommonQuestLikeProtocol { + func likeButtonDidTap(answerID: Int) { + ByeBooLogger.debug("answerID: \(answerID)") + viewModel.action(.likeButtonDidTap(answerID: answerID)) + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewController/ParentQuestViewController.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewController/ParentQuestViewController.swift index 92e6cca4..1b3fd2e0 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewController/ParentQuestViewController.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewController/ParentQuestViewController.swift @@ -11,8 +11,9 @@ final class ParentQuestViewController: BaseViewController, ToastPres private let tabBar: TopTabBar private let containerView = UIView() - private let controllers: [UIViewController] + private(set) var controllers: [UIViewController] private var currentViewController: UIViewController? + private var selectedIndex: Int = 0 init(items: T.AllCases) { self.tabBar = TopTabBar(items: Array(items)) @@ -27,6 +28,11 @@ final class ParentQuestViewController: BaseViewController, ToastPres override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(true) tabBarController?.tabBar.isHidden = false + + guard controllers.indices.contains(selectedIndex) else { return } + + show(controllers[selectedIndex]) + tabBar.select(index: selectedIndex) } override func viewDidLoad() { @@ -35,17 +41,20 @@ final class ParentQuestViewController: BaseViewController, ToastPres setLayout() bind() - if let controller = controllers.first { - show(controller) - } - NotificationCenter.default.addObserver( - self, - selector: #selector(handleToast(_:)), - name: .showToastMessage, - object: nil + self, + selector: #selector(handleToast(_:)), + name: .showToastMessage, + object: nil ) - + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + if navigationController?.topViewController === self { + selectedIndex = 0 + } } override func setView() { @@ -57,6 +66,8 @@ final class ParentQuestViewController: BaseViewController, ToastPres func selectTab(index: Int) { guard controllers.indices.contains(index) else { return } + + selectedIndex = index show(controllers[index]) tabBar.select(index: index) } @@ -82,7 +93,7 @@ final class ParentQuestViewController: BaseViewController, ToastPres self.presentToastMessage(type: .block) case .report: self.presentToastMessage(type: .report) - case .edit, .delete: + case .questEdit, .commentEdit, .delete: return } } @@ -111,6 +122,7 @@ extension ParentQuestViewController { else { return } + selectedIndex = index show(controllers[index]) } } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewController/QuestCheckViewController.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewController/QuestCheckViewController.swift index cfc8fb5d..872f7916 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewController/QuestCheckViewController.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewController/QuestCheckViewController.swift @@ -16,12 +16,14 @@ final class QuestCheckViewController: BaseViewController { private static let lastStep = 5 private let questsCheckView = ViewAllQuestBaseView(headerView: QuestCheckHeaderView()) + private let finishEmptyView = QuestEmptyView() private let viewModel: ProgressingQuestsViewModel var coordinator: QuestCheckCoordinating? private var cancellable = Set() private var journeyType: JourneyType = .recording private var isFirst: Bool = true + private var pendingQuestNumber: Int? init(viewModel: ProgressingQuestsViewModel) { self.viewModel = viewModel @@ -78,6 +80,10 @@ final class QuestCheckViewController: BaseViewController { $0.backgroundColor = .grayscale900 } } + + override func setAddTarget() { + finishEmptyView.button.addTarget(self, action: #selector(goBoriButtonDidTap), for: .touchUpInside) + } } extension QuestCheckViewController: ToastPresentable, ToastErrorHandler { @@ -102,10 +108,15 @@ extension QuestCheckViewController: ToastPresentable, ToastErrorHandler { switch (name, journey, quests) { case let (.success(name), .success(journey), .success(quests)): self?.updateQuestMainUI(name: name, journey: journey, quests: quests) + if let questNumber = self?.pendingQuestNumber { + self?.presentCurrentQuestModal(questNumber: questNumber) + self?.pendingQuestNumber = nil + } case (.success(_), .success(_), .failure(_)): self?.coordinator?.moveQuestStart() - case (.success(_), .failure(.notFound), .failure(_)): - self?.coordinator?.moveFinishQuest() + case let (.success(name), .failure(.notFound), .failure(_)): + self?.finishEmptyView.configure(name: name) + self?.view = self?.finishEmptyView case (_, .failure(let error), _), (_, _, .failure(let error)): self?.handleError(error) default: @@ -152,6 +163,10 @@ extension QuestCheckViewController: ToastPresentable, ToastErrorHandler { extension QuestCheckViewController { + func setPendingQuestNumber(_ questNumber: Int) { + self.pendingQuestNumber = questNumber + } + private func updateQuestMainUI( name: String, journey: JourneyEntity, @@ -206,6 +221,19 @@ extension QuestCheckViewController { let collectionView = questsCheckView.questCollectionView collectionView.scrollToHeader(at: sectionIndex) } + + private func presentCurrentQuestModal(questNumber: Int) { + let quest = viewModel.findQuest(questNumber: questNumber) + coordinator?.presentQuestModal(quest: quest) + } +} + +extension QuestCheckViewController { + + @objc + private func goBoriButtonDidTap() { + coordinator?.moveFinishQuest() + } } extension QuestCheckViewController: UICollectionViewDelegate { diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewController/WriteActiveTypeQuestViewController.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewController/WriteActiveTypeQuestViewController.swift index b20bf368..e87a6f90 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewController/WriteActiveTypeQuestViewController.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewController/WriteActiveTypeQuestViewController.swift @@ -92,13 +92,7 @@ final class WriteActiveTypeQuestViewController: WriteQuestBaseViewController? + init(fetchAIAnswerUseCase: FetchAIAnswerUseCase) { self.fetchAIAnswerUseCase = fetchAIAnswerUseCase @@ -34,6 +36,7 @@ final class AIAnswerViewModel { extension AIAnswerViewModel: ViewModelType { enum Input { case viewDidLoad(questID: Int, isAIAnswerExists: Bool) + case viewDidDisappear } struct Output { @@ -48,13 +51,17 @@ extension AIAnswerViewModel: ViewModelType { self.isAnswerExists = isExists fetchAIAnswer() + case .viewDidDisappear: + cancelTask() } } } extension AIAnswerViewModel { private func fetchAIAnswer() { - Task { + fetchAIAnswerTask?.cancel() + + fetchAIAnswerTask = Task { do { AILoadingSubject.send(true) @@ -72,6 +79,12 @@ extension AIAnswerViewModel { AIResultSubject.send(.failure(error)) AILoadingSubject.send(false) } + fetchAIAnswerTask = nil } } + + private func cancelTask() { + fetchAIAnswerTask?.cancel() + fetchAIAnswerTask = nil + } } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewModel/ArchiveQuestViewModel.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewModel/ArchiveQuestViewModel.swift index f3765e2a..c841366c 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewModel/ArchiveQuestViewModel.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewModel/ArchiveQuestViewModel.swift @@ -69,10 +69,7 @@ extension ArchiveQuestViewModel { guard let entity = self.entity else { return } resultSubject.send(.success(entity)) loadingSubject.send(false) - } catch { - guard let error = error as? ByeBooError else { - return - } + } catch(let error as ByeBooError) { resultSubject.send(.failure(error)) loadingSubject.send(false) } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewModel/CommonQuestHistoryViewModel.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewModel/CommonQuestHistoryViewModel.swift new file mode 100644 index 00000000..cd5f4a41 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewModel/CommonQuestHistoryViewModel.swift @@ -0,0 +1,155 @@ +// +// CommonQuestHistoryViewModel.swift +// ByeBoo-iOS +// +// Created by 이나연 on 5/5/26. +// + +import Combine +import Foundation + +final class CommonQuestHistoryViewModel { + private let fetchCommonQuestCommentsUseCase: FetchCommonQuestDetailUseCase + private let postCommonQuestLikeUseCase: PostCommonQuestLikeUseCase + private let postCommentUseCase: PostCommonQuestCommentUseCase + private let patchCommentUseCase: EditCommentReplyUseCase + + private let likeCountSubject = PassthroughSubject, Never>.init() + private let fetchQuestDetailSubject: PassthroughSubject, Never> = .init() + private let postCommentSubject: PassthroughSubject, Never> = .init() + private let patchCommentSubject: PassthroughSubject, Never> = .init() + + private var entity: CommonQuestDetailEntity? = nil + private var cancellables = Set() + private var likeTasks: [Int: Task] = [:] + private(set) var output: Output + + init( + fetchCommonQuestCommentsUseCase: FetchCommonQuestDetailUseCase, + postCommentUseCase: PostCommonQuestCommentUseCase, + patchCommentUseCase: EditCommentReplyUseCase, + postCommonQuestLikeUseCase: PostCommonQuestLikeUseCase + ) { + self.fetchCommonQuestCommentsUseCase = fetchCommonQuestCommentsUseCase + self.postCommentUseCase = postCommentUseCase + self.postCommonQuestLikeUseCase = postCommonQuestLikeUseCase + self.patchCommentUseCase = patchCommentUseCase + + output = Output( + fetchCommonQuestDetailPublisher: fetchQuestDetailSubject.eraseToAnyPublisher(), + postCommentPublisher: postCommentSubject.eraseToAnyPublisher(), + patchCommentPublisher: patchCommentSubject.eraseToAnyPublisher(), + commonQuestLikeCountPublisher: likeCountSubject.eraseToAnyPublisher() + ) + } +} + +extension CommonQuestHistoryViewModel: ViewModelType { + enum Input { + case fetchQuestDetail(answerID: Int) + case postComment(answerID: Int, content: String) + case likeButtonDidTap(answerID: Int) + case patchComment(answerID: Int, commentID: Int, content: String) + } + + struct Output { + let fetchCommonQuestDetailPublisher: AnyPublisher, Never> + let postCommentPublisher: AnyPublisher,Never> + let patchCommentPublisher: AnyPublisher,Never> + let commonQuestLikeCountPublisher: AnyPublisher, Never> + } + + func action(_ trigger: Input) { + switch trigger { + case .fetchQuestDetail(let answerID): + Task { + await fetchCommonQuestComments(answerID: answerID) + } + case .postComment(let answerID, let content): + postComment(answerID: answerID, content: content) + case .likeButtonDidTap(let answerID): + postCommonQuestLike(answerID: answerID) + case .patchComment(let answerID, let commentID, let content): + patchComment(answerID: answerID, commentID: commentID , content: content) + } + } +} + +extension CommonQuestHistoryViewModel { + var question: String { + return entity?.question ?? "" + } + + var detailEntity: CommonQuestAnswerDetailEntity? { + return entity?.answer + } + + var commentList: [CommonQuestCommentEntity]? { + return entity?.comments + } + + func getComment(commentID: Int) -> CommonQuestCommentEntity? { + entity?.comments.first { $0.commentID == commentID } + } +} + +extension CommonQuestHistoryViewModel { + private func fetchCommonQuestComments(answerID: Int) async { + do { + entity = try await fetchCommonQuestCommentsUseCase.execute(answerID: answerID) + guard let entity else { return } + ByeBooLogger.debug("entity \(entity)") + fetchQuestDetailSubject.send(.success(entity)) + } catch { + guard let error = error as? ByeBooError else { return } + fetchQuestDetailSubject.send(.failure(error)) + } + } + + private func postComment(answerID: Int, content: String) { + Task { + do { + _ = try await postCommentUseCase.execute(content: content, targetID: answerID) + ByeBooLogger.debug("content: \(content)") + await fetchCommonQuestComments(answerID: answerID) + postCommentSubject.send(.success(())) + } catch(let error as ByeBooError) { + postCommentSubject.send(.failure(error)) + } + } + } + + private func postCommonQuestLike(answerID: Int) { + likeTasks[answerID]?.cancel() + + likeTasks[answerID] = Task { + do { + let entity = try await postCommonQuestLikeUseCase.execute(answerID: answerID) + try Task.checkCancellation() + likeCountSubject.send(.success((answerID, entity))) + } catch is CancellationError { + ByeBooLogger.debug("Task 취소됨") + } catch { + guard let error = error as? ByeBooError else { + return + } + guard !Task.isCancelled else { return } + likeCountSubject.send(.failure(error)) + } + likeTasks[answerID] = nil + } + } + + private func patchComment(answerID: Int, commentID: Int, content: String) { + Task { + do { + _ = try await patchCommentUseCase.execute(content: content, targetID: commentID) + ByeBooLogger.debug("content: \(content)") + await fetchCommonQuestComments(answerID: answerID) + patchCommentSubject.send(.success(())) + } catch(let error as ByeBooError) { + patchCommentSubject.send(.failure(error)) + } + } + } +} 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..f6098b4e 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewModel/CommonQuestMyAnswerViewModel.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewModel/CommonQuestMyAnswerViewModel.swift @@ -13,8 +13,12 @@ final class CommonQuestMyAnswerViewModel { private let cancellables = Set() private let nameSubject = PassthroughSubject, Never>.init() private let answersSubject = PassthroughSubject, Never>.init() + private let likeCountSubject = PassthroughSubject, Never>.init() + private let getUserNameUseCase: GetUserNameUseCase private let fetchCommonQuestMyAnswersUseCase: FetchCommonQuestMyAnswersUseCase + private let postCommonQuestLikeUseCase: PostCommonQuestLikeUseCase + private(set) var output: Output private var commonQuestAnswers: CommonQuestMyAnswersEntity? @@ -22,15 +26,21 @@ final class CommonQuestMyAnswerViewModel { private(set) var hasMorePages = true private var nextCursor: Int? = nil + private var likeTasks: [Int: Task] = [:] + init( getUserNameUseCase: GetUserNameUseCase, - fetchCommonQuestMyAnswersUseCase: FetchCommonQuestMyAnswersUseCase + fetchCommonQuestMyAnswersUseCase: FetchCommonQuestMyAnswersUseCase, + postCommonQuestLikeUseCase: PostCommonQuestLikeUseCase ) { self.getUserNameUseCase = getUserNameUseCase self.fetchCommonQuestMyAnswersUseCase = fetchCommonQuestMyAnswersUseCase + self.postCommonQuestLikeUseCase = postCommonQuestLikeUseCase + self.output = Output( namePublisher: nameSubject.eraseToAnyPublisher(), - answersPublisher: answersSubject.eraseToAnyPublisher() + answersPublisher: answersSubject.eraseToAnyPublisher(), + commonQuestLikeCountPublisher: likeCountSubject.eraseToAnyPublisher() ) } @@ -53,9 +63,30 @@ final class CommonQuestMyAnswerViewModel { answers.append(contentsOf: result.answers) answersSubject.send(.success(())) + } catch(let error as ByeBooError) { + answersSubject.send(.failure(error)) + } + } + } + + private func postCommonQuestLike(answerID: Int) { + likeTasks[answerID]?.cancel() + + likeTasks[answerID] = Task { + do { + let entity = try await postCommonQuestLikeUseCase.execute(answerID: answerID) + try Task.checkCancellation() + likeCountSubject.send(.success((answerID: answerID, entity))) + } catch is CancellationError { + ByeBooLogger.debug("Task 취소됨") } catch { - answersSubject.send(.failure(error as! ByeBooError)) + guard let error = error as? ByeBooError else { + return + } + guard !Task.isCancelled else { return } + likeCountSubject.send(.failure(error)) } + likeTasks[answerID] = nil } } } @@ -65,11 +96,13 @@ extension CommonQuestMyAnswerViewModel: ViewModelType { enum Input { case viewWillAppear case scrollAnswer + case likeButtonDidTap(answerID: Int) } struct Output { let namePublisher: AnyPublisher, Never> let answersPublisher: AnyPublisher, Never> + let commonQuestLikeCountPublisher: AnyPublisher, Never> } func action(_ trigger: Input) { @@ -79,6 +112,8 @@ extension CommonQuestMyAnswerViewModel: ViewModelType { fetchUserCommonQuestAnswers() case .scrollAnswer: fetchUserCommonQuestAnswers(cursor: nextCursor) + case .likeButtonDidTap(let answerID): + postCommonQuestLike(answerID: answerID) } } } @@ -89,6 +124,10 @@ extension CommonQuestMyAnswerViewModel { answers.count } + func indexOfAnswer(answerID: Int) -> Int? { + answers.firstIndex { $0.answerID == answerID } + } + func getAnswer(at index: Int) -> CommonQuestMyAnswerEntity? { guard index >= 0 && index < answersCount else { return nil @@ -103,7 +142,10 @@ extension CommonQuestMyAnswerViewModel { answerID: answer.answerID, writtenAt: displayDate, content: answer.content, - question: answer.question + question: answer.question, + isLiked: answer.isLiked, + likeCount: answer.likeCount, + commentCount: answer.commentCount ) } } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewModel/CommonQuestReplyViewModel.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewModel/CommonQuestReplyViewModel.swift new file mode 100644 index 00000000..77864cb0 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewModel/CommonQuestReplyViewModel.swift @@ -0,0 +1,128 @@ +// +// CommonQuestReplyViewModel.swift +// ByeBoo-iOS +// +// Created by 이나연 on 5/8/26. +// + +import Foundation + +import Combine + +final class CommonQuestReplyViewModel { + + private let fetchRepliesUseCase: FetchCommonQuestRepliesUseCase + private let postReplyUseCase: PostCommonQuestReplyUseCase + private let patchReplyUseCase: EditCommentReplyUseCase + + private let fetchRepliesSubject: PassthroughSubject, Never> = .init() + private let postReplySubject: PassthroughSubject, Never> = .init() + private let patchReplySubject: PassthroughSubject, Never> = .init() + + private var cancellables = Set() + private(set) var output: Output + + private var comment: CommonQuestCommentEntity? + private var replies: [CommonQuestCommentEntity] = [] + + init( + fetchRepliesUseCase: FetchCommonQuestRepliesUseCase, + postReplyUseCase: PostCommonQuestReplyUseCase, + patchReplyUseCase: EditCommentReplyUseCase + ) { + self.fetchRepliesUseCase = fetchRepliesUseCase + self.postReplyUseCase = postReplyUseCase + self.patchReplyUseCase = patchReplyUseCase + + output = Output( + fetchReplyListPublisher: fetchRepliesSubject.eraseToAnyPublisher(), + postReplyPublisher: postReplySubject.eraseToAnyPublisher(), + patchReplyPublisher: patchReplySubject.eraseToAnyPublisher() + ) + } + + func getCommentContent() -> CommonQuestCommentEntity { + return CommonQuestCommentEntity.toCommentStub() + } + + func getReplyList() -> [CommonQuestCommentEntity] { + return CommonQuestCommentEntity.toReplyListStub() + } + + func setComment(_ comment: CommonQuestCommentEntity) { + self.comment = comment + } + + func getComment(commentID: Int) -> CommonQuestCommentEntity? { + if comment?.commentID == commentID { return comment } + return replies.first { $0.commentID == commentID } + } +} + +extension CommonQuestReplyViewModel { + enum Input { + case fetchReplyList(commentID: Int) + case postReply(commentID: Int, content: String) + case editReply(commentID: Int, editingCommentID: Int, content: String) + } + + struct Output { + let fetchReplyListPublisher: AnyPublisher, Never> + let postReplyPublisher: AnyPublisher,Never> + let patchReplyPublisher: AnyPublisher,Never> + } + + func action(_ trigger: Input) { + switch trigger { + case .fetchReplyList(let commentID): + Task { + await fetchReplyList(commentID: commentID) + } + case .postReply(let commentID, let content): + postReply(commentID: commentID, content: content) + case .editReply(let commentID, let editingCommentID, let content): + patchReply(commentID: commentID, editingCommentID: editingCommentID, content: content) + } + } +} + +extension CommonQuestReplyViewModel { + private func fetchReplyList(commentID: Int) async { + do { + let result = try await fetchRepliesUseCase.execute(commentID: commentID) + replies = result.replies + ByeBooLogger.debug("답글 목록 \(result)") + fetchRepliesSubject.send(.success(result)) + } catch { + guard let error = error as? ByeBooError else { return } + fetchRepliesSubject.send(.failure(error)) + } + } + + private func postReply(commentID: Int, content: String) { + Task { + do { + _ = try await postReplyUseCase.execute(content: content, commentID: commentID ) + ByeBooLogger.debug("답글 달기 성공") + await fetchReplyList(commentID: commentID) + postReplySubject.send(.success(())) + } catch { + guard let error = error as? ByeBooError else { return } + postReplySubject.send(.failure(error)) + } + } + } + + private func patchReply(commentID: Int, editingCommentID: Int, content: String) { + Task { + do { + _ = try await patchReplyUseCase.execute(content: content, targetID: editingCommentID) + await fetchReplyList(commentID: commentID) + patchReplySubject.send(.success(())) + } catch { + guard let error = error as? ByeBooError else { return } + patchReplySubject.send(.failure(error)) + } + } + } +} 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 06b6f30f..1bee4d55 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewModel/CommonQuestViewModel.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewModel/CommonQuestViewModel.swift @@ -10,12 +10,13 @@ import UIKit final class CommonQuestViewModel { - private let cancellables = Set() + private var cancellables = Set() private let commonQuestSubject = PassthroughSubject, Never>.init() + private let likeCountSubject = PassthroughSubject, Never>.init() + private let fetchCommonQuestByDateUseCase: FetchCommonQuestByDateUseCase - private let minute: Double = 60 - private let hour: Double = 3600 - private let day: Double = 86400 + private let postCommonQuestLikeUseCase: PostCommonQuestLikeUseCase + private let formatElapsedTimeUseCase: FormatElapsedTimeUseCase private(set) var output: Output private var commonQuest: CommonQuestAnswersEntity? @@ -23,11 +24,19 @@ final class CommonQuestViewModel { private(set) var hasMorePages = true private var nextCursor: Int? = nil private var currentDate: String = DateFormatter.toAPIDateString(from: .now) + private var likeTasks: [Int: Task] = [:] - init(fetchCommonQuestByDateUseCase: FetchCommonQuestByDateUseCase) { + init( + fetchCommonQuestByDateUseCase: FetchCommonQuestByDateUseCase, + postCommonQuestLikeUseCase: PostCommonQuestLikeUseCase, + formatElapsedTimeUseCase: FormatElapsedTimeUseCase + ) { self.fetchCommonQuestByDateUseCase = fetchCommonQuestByDateUseCase + self.postCommonQuestLikeUseCase = postCommonQuestLikeUseCase + self.formatElapsedTimeUseCase = formatElapsedTimeUseCase self.output = Output( - commonQuestPublisher: commonQuestSubject.eraseToAnyPublisher() + commonQuestPublisher: commonQuestSubject.eraseToAnyPublisher(), + commonQuestLikeCountPublisher: likeCountSubject.eraseToAnyPublisher() ) } @@ -52,9 +61,30 @@ final class CommonQuestViewModel { } commonQuestSubject.send(.success(())) + } catch(let error as ByeBooError) { + commonQuestSubject.send(.failure(error)) + } + } + } + + private func postCommonQuestLike(answerID: Int) { + likeTasks[answerID]?.cancel() + + likeTasks[answerID] = Task { + do { + let entity = try await postCommonQuestLikeUseCase.execute(answerID: answerID) + try Task.checkCancellation() + likeCountSubject.send(.success((answerID: answerID, entity))) + } catch is CancellationError { + ByeBooLogger.debug("Task 취소됨") } catch { - commonQuestSubject.send(.failure(error as! ByeBooError)) + guard let error = error as? ByeBooError else { + return + } + guard !Task.isCancelled else { return } + likeCountSubject.send(.failure(error)) } + likeTasks[answerID] = nil } } } @@ -65,10 +95,12 @@ extension CommonQuestViewModel: ViewModelType { case viewWillAppear case moveDateButtonDidTap(selectedDate: String) case scrollAnswer + case likeButtonDidTap(answerID: Int) } struct Output { let commonQuestPublisher: AnyPublisher, Never> + let commonQuestLikeCountPublisher: AnyPublisher, Never> } func action(_ trigger: Input) { @@ -85,32 +117,13 @@ extension CommonQuestViewModel: ViewModelType { return } fetchCommonQuestByDate(date: currentDate, cursor: nextCursor) + case .likeButtonDidTap(let answerID): + postCommonQuestLike(answerID: answerID) } } } extension CommonQuestViewModel { - - private enum ProfileIcon: String, CaseIterable { - case sad = "SADNESS" - case selfUnderstanding = "SELF_UNDERSTANDING" - case soso = "SO_SO" - case relieved = "RELIEVED" - - var image: UIImage { - switch self { - case .sad: - return .sadnessBadge - case .selfUnderstanding: - return .selfUnderstandingBadge - case .soso: - return .sosoBadge - case .relieved: - return .relievedBadge - } - } - } - var question: String { commonQuest?.question ?? "" } @@ -134,7 +147,7 @@ extension CommonQuestViewModel { var isUserAnswered: Bool { commonQuest?.isAnswered ?? false } - + func getAnswer(at index: Int) -> CommonQuestAnswerEntity? { guard index >= 0 && index < answers.count else { return nil @@ -142,38 +155,24 @@ extension CommonQuestViewModel { return answers[index] } - func getProfileIcon(at index: Int) -> UIImage? { + func getAnswerID(at index: Int) -> Int? { guard index >= 0 && index < answers.count else { return nil } - - let iconString = self.answers[index].profileIcon - let profileIcon = ProfileIcon.allCases - .first { $0.rawValue == iconString }? - .image - return profileIcon + return answers[index].answerID + } + + func indexOfAnswer(answerID: Int) -> Int? { + answers.firstIndex { $0.answerID == answerID } + } + + func getProfileIcon(at index: Int) -> UIImage? { + guard index >= 0 && index < answers.count else { return nil } + return ProfileIcon.image(for: answers[index].profileIcon) } 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 ..= 0 && index < answers.count else { return nil } + return ServerDateFormatter.shared.relativeTimeString(from: answers[index].writtenAt) } } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewModel/ProgressingQuestsViewModel.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewModel/ProgressingQuestsViewModel.swift index 60ea152a..eefb2b9a 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewModel/ProgressingQuestsViewModel.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewModel/ProgressingQuestsViewModel.swift @@ -58,12 +58,8 @@ final class ProgressingQuestsViewModel { let journeyEntity = try await fetchUserJourneyUseCase.execute() journeySubject.send(.success(journeyEntity)) loadingSubject.send(false) - } catch { - journeySubject.send( - .failure( - error as? ByeBooError ?? ByeBooError.unknownError - ) - ) + } catch(let error as ByeBooError) { + journeySubject.send(.failure(error)) loadingSubject.send(false) } } @@ -77,8 +73,8 @@ final class ProgressingQuestsViewModel { self.setQuestTimer() questsSubject.send(.success(questsEntity)) loadingSubject.send(false) - } catch { - questsSubject.send(.failure(error as! ByeBooError)) + } catch(let error as ByeBooError) { + questsSubject.send(.failure(error)) loadingSubject.send(false) } } @@ -91,7 +87,7 @@ final class ProgressingQuestsViewModel { questOpenTime: questsEntity?.questOpenTime, currentTime: questsEntity?.currentTime ) - + timeCancellabels?.cancel() timeCancellabels = Timer.publish(every: 1.0, on: .main, in: .common) .autoconnect() @@ -99,7 +95,7 @@ final class ProgressingQuestsViewModel { guard let self = self else { return } - + if remainingSeconds > 0 { remainingSeconds -= 1 let time = self.formatRemainingTime(seconds: remainingSeconds) @@ -182,6 +178,16 @@ extension ProgressingQuestsViewModel { } return .ongoing } + + func findQuest(questNumber: Int) -> QuestEntity? { + for (sectionIndex, step) in steps.enumerated() { + if let itemIndex = step.quests.firstIndex(where: { $0.questNumber == questNumber }) { + return getQuest(section: sectionIndex, item: itemIndex) + } + } + + return nil + } } extension ProgressingQuestsViewModel: ViewModelType { diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewModel/QuestStartViewModel.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewModel/QuestStartViewModel.swift index 8ec6d920..ac62c7e8 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewModel/QuestStartViewModel.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewModel/QuestStartViewModel.swift @@ -77,8 +77,8 @@ extension QuestStartViewModel { do { try await startJourneyUseCase.execute() startResultSubject.send(.success(true)) - } catch { - startResultSubject.send(.failure(error as? ByeBooError ?? ByeBooError.unknownError)) + } catch(let error as ByeBooError){ + startResultSubject.send(.failure(error)) } } } @@ -88,10 +88,7 @@ extension QuestStartViewModel { do { let _ = try await postJourneyUseCase.execute(journey: JourneyType.titleToEnum(journey) ?? .recording) startResultSubject.send(.success(true)) - } catch { - guard let error = error as? ByeBooError else { - return - } + } catch(let error as ByeBooError) { ByeBooLogger.error(error as ByeBooError) startResultSubject.send(.failure((error))) } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewModel/QuestTipViewModel.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewModel/QuestTipViewModel.swift index 9a205790..f2883af2 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewModel/QuestTipViewModel.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewModel/QuestTipViewModel.swift @@ -51,10 +51,7 @@ extension QuestTipViewModel { do { let tips = try await useCase.fetchQuestTips(questID: questID) questTipSubject.send((.success(tips))) - } catch { - guard let error = error as? ByeBooError else { - return - } + } catch(let error as ByeBooError) { ByeBooLogger.error(error as ByeBooError) questTipSubject.send(.failure(error)) } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewModel/WriteQuestTypeViewModel.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewModel/WriteQuestTypeViewModel.swift index b69bbec4..32300d20 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewModel/WriteQuestTypeViewModel.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Quest/ViewModel/WriteQuestTypeViewModel.swift @@ -131,10 +131,7 @@ extension WriteQuestionTypeViewModel { do { let questInfo = try await getQuestInfoUseCase.execute(questID: questID) questInfoResultSubject.send(.success(questInfo)) - } catch { - guard let error = error as? ByeBooError else { - return - } + } catch(let error as ByeBooError) { questInfoResultSubject.send(.failure(error)) } } @@ -145,10 +142,7 @@ extension WriteQuestionTypeViewModel { do { try await saveQuestTypeUseCase.execute(questID: questID, answer: answer, emotionState: emotionState) didSuccessPostSubject.send(.success(())) - } catch { - guard let error = error as? ByeBooError else { - return - } + } catch(let error as ByeBooError) { didSuccessPostSubject.send(.failure(error as ByeBooError)) ByeBooLogger.error(error) } @@ -160,10 +154,7 @@ extension WriteQuestionTypeViewModel { do { try await editQuestTypeUseCase.execute(questID: questID, answer: answer) didSuccessEditSubject.send(.success(())) - } catch { - guard let error = error as? ByeBooError else { - return - } + } catch(let error as ByeBooError) { didSuccessEditSubject.send(.failure(error as ByeBooError)) ByeBooLogger.error(error) } @@ -175,10 +166,7 @@ extension WriteQuestionTypeViewModel { do { try await saveCommonQuestUseCase.execute(questID: questID, answer: answer) didSuccessPostSubject.send(.success(())) - } catch { - guard let error = error as? ByeBooError else { - return - } + } catch(let error as ByeBooError) { didSuccessPostSubject.send(.failure(error as ByeBooError)) ByeBooLogger.error(error) } @@ -200,10 +188,7 @@ extension WriteQuestionTypeViewModel { do { try await updateCommonQuestUseCase.execute(answerID: answerID, answer: answer) didSucessUpdateSubject.send(.success(())) - } catch { - guard let error = error as? ByeBooError else { - return - } + } catch(let error as ByeBooError) { didSuccessPostSubject.send(.failure(error as ByeBooError)) ByeBooLogger.error(error) } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/PresentationDependencyAssembler.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/PresentationDependencyAssembler.swift index b4a49e37..a4f136fa 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/PresentationDependencyAssembler.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/PresentationDependencyAssembler.swift @@ -91,9 +91,7 @@ struct PresentationDependencyAssembler: DependencyAssembler { } DIContainer.shared.register(type: ArchiveQuestViewModel.self) { container in - guard let questAnswerUseCase = container.resolve(type: QuestAnswerUseCase.self), - let fetchAIAnswerUseCase = container.resolve(type: FetchAIAnswerUseCase.self) - else { + guard let questAnswerUseCase = container.resolve(type: QuestAnswerUseCase.self) else { ByeBooLogger.error(ByeBooError.DIFailedError) return } @@ -103,14 +101,15 @@ struct PresentationDependencyAssembler: DependencyAssembler { ) } - DIContainer.shared.register(type: HomeViewModel.self) { container in guard let characterUseCase = container.resolve(type: FetchCharacterDialogueUseCase.self), let questStatusUseCase = container.resolve(type: FetchQuestStatusUseCase.self), let setHelperUseCase = container.resolve(type: SetHelperUseCase.self), let fetchUserJourneyUseCase = container.resolve(type: FetchUserJourneyUseCase.self), let getUserNameUseCase = container.resolve(type: GetUserNameUseCase.self), - let getHelperUseCase = container.resolve(type: GetHelperUseCase.self) else { + let getHelperUseCase = container.resolve(type: GetHelperUseCase.self), + let fetchHasUnreadNotificationUseCase = container.resolve(type: FetchHasUnreadNotificationUseCase.self) + else { ByeBooLogger.error(ByeBooError.DIFailedError) return } @@ -121,7 +120,8 @@ struct PresentationDependencyAssembler: DependencyAssembler { fetchUserJourneyUseCase: fetchUserJourneyUseCase, getUserNameUseCase: getUserNameUseCase, setHelperUseCase: setHelperUseCase, - getHelperUseCase: getHelperUseCase + getHelperUseCase: getHelperUseCase, + fetchHasUnreadNotificationUseCase: fetchHasUnreadNotificationUseCase ) } @@ -265,14 +265,15 @@ struct PresentationDependencyAssembler: DependencyAssembler { } DIContainer.shared.register(type: SplashViewModel.self) { container in - guard let autoLoginUseCase = container.resolve( - type: AutoLoginUseCase.self - ) - else { + guard let autoLoginUseCase = container.resolve(type: AutoLoginUseCase.self), + let checkForceUpdateUseCase = container.resolve(type: CheckForceUpdateUseCase.self) else { ByeBooLogger.error(ByeBooError.DIFailedError) return } - return SplashViewModel(autoLoginUseCase: autoLoginUseCase) + return SplashViewModel( + autoLoginUseCase: autoLoginUseCase, + checkForceUpdateUseCase: checkForceUpdateUseCase + ) } DIContainer.shared.register(type: FinishJourneyViewModel.self) { container in @@ -289,35 +290,40 @@ struct PresentationDependencyAssembler: DependencyAssembler { } DIContainer.shared.register(type: CommonQuestViewModel.self) { container in - guard let fetchCommonQuestByDateUseCase = container.resolve(type: FetchCommonQuestByDateUseCase.self) else { + guard let fetchCommonQuestByDateUseCase = container.resolve(type: FetchCommonQuestByDateUseCase.self), + let postCommonQuestLikeUseCase = container.resolve(type: PostCommonQuestLikeUseCase.self), + let formatElapsedTimeUseCase = container.resolve(type: FormatElapsedTimeUseCase.self) else { ByeBooLogger.error(ByeBooError.DIFailedError) return } return CommonQuestViewModel( - fetchCommonQuestByDateUseCase: fetchCommonQuestByDateUseCase + fetchCommonQuestByDateUseCase: fetchCommonQuestByDateUseCase, + postCommonQuestLikeUseCase: postCommonQuestLikeUseCase, + formatElapsedTimeUseCase: formatElapsedTimeUseCase ) } DIContainer.shared.register(type: CommonQuestMyAnswerViewModel.self) { container in guard let getUserNameUseCase = container.resolve(type: GetUserNameUseCase.self), - let fetchCommonQuestMyAnswersUseCase = container.resolve(type: FetchCommonQuestMyAnswersUseCase.self) else { + let fetchCommonQuestMyAnswersUseCase = container.resolve(type: FetchCommonQuestMyAnswersUseCase.self), + let postCommonQuestLikeUseCase = container.resolve(type: PostCommonQuestLikeUseCase.self) else { ByeBooLogger.error(ByeBooError.DIFailedError) return } return CommonQuestMyAnswerViewModel( getUserNameUseCase: getUserNameUseCase, - fetchCommonQuestMyAnswersUseCase: fetchCommonQuestMyAnswersUseCase + fetchCommonQuestMyAnswersUseCase: fetchCommonQuestMyAnswersUseCase, + postCommonQuestLikeUseCase: postCommonQuestLikeUseCase ) } DIContainer.shared.register(type: CommonQuestBottomSheetViewModel.self) { container in - guard let blockUserUseCase = container.resolve( - type: BlockUserUseCase.self - ), + guard let blockUserUseCase = container.resolve(type: BlockUserUseCase.self), let reportQuestAnswerUseCase = container.resolve(type: ReportsCommonQuestAnswerUseCase.self), - let deleteCommonQuestUseCase = container.resolve(type: DeleteCommonQuestUseCase.self) + let deleteCommonQuestUseCase = container.resolve(type: DeleteCommonQuestUseCase.self), + let deleteCommentReplyUseCase = container.resolve(type: DeleteCommentReplyUseCase.self) else { ByeBooLogger.error(ByeBooError.DIFailedError) return @@ -326,7 +332,8 @@ struct PresentationDependencyAssembler: DependencyAssembler { return CommonQuestBottomSheetViewModel( blockUserUseCase: blockUserUseCase, reportCommonQuestUseCase: reportQuestAnswerUseCase, - deleteCommonQuestUseCase: deleteCommonQuestUseCase + deleteCommonQuestUseCase: deleteCommonQuestUseCase, + deleteCommentReplyUseCase: deleteCommentReplyUseCase ) } @@ -352,5 +359,55 @@ struct PresentationDependencyAssembler: DependencyAssembler { deleteBlockedUserUseCase: deleteBlockedUserUseCase ) } + + DIContainer.shared.register(type: NotificationsViewModel.self) { container in + guard let fetchNotificationListUseCase = container.resolve(type: FetchNotificationListUseCase.self), + let formatElapsedTimeUseCase = container.resolve(type: FormatElapsedTimeUseCase.self), + let readNotificationUseCase = container.resolve(type: ReadNotificationUseCase.self), + let readAllNotificationsUseCase = container.resolve(type: ReadAllNotificationsUseCase.self) + else { + ByeBooLogger.error(ByeBooError.DIFailedError) + return + } + + return NotificationsViewModel( + fetchNotificationListUseCase: fetchNotificationListUseCase, + formatElapsedTimeUseCase: formatElapsedTimeUseCase, + readNotificationUseCase: readNotificationUseCase, + readAllNotificationsUseCase: readAllNotificationsUseCase + ) + } + + DIContainer.shared.register(type: CommonQuestHistoryViewModel.self) { container in + guard let fetchCommonQuestDetailUseCase = container.resolve(type: FetchCommonQuestDetailUseCase.self), + let postCommentUseCase = container.resolve(type: PostCommonQuestCommentUseCase.self), + let patchCommentUseCase = container.resolve(type: EditCommentReplyUseCase.self), + let postCommonQuestLikeUseCase = container.resolve(type: PostCommonQuestLikeUseCase.self) else { + ByeBooLogger.error(ByeBooError.DIFailedError) + return + } + + return CommonQuestHistoryViewModel( + fetchCommonQuestCommentsUseCase: fetchCommonQuestDetailUseCase, + postCommentUseCase: postCommentUseCase, + patchCommentUseCase: patchCommentUseCase, + postCommonQuestLikeUseCase: postCommonQuestLikeUseCase + ) + } + + DIContainer.shared.register(type: CommonQuestReplyViewModel.self) { container in + guard let fetchReplyUseCase = container.resolve(type: FetchCommonQuestRepliesUseCase.self), + let postReplyUseCase = container.resolve(type: PostCommonQuestReplyUseCase.self), + let patchReplyUseCase = container.resolve(type: EditCommentReplyUseCase.self ) else { + ByeBooLogger.error(ByeBooError.DIFailedError) + return + } + + return CommonQuestReplyViewModel( + fetchRepliesUseCase: fetchReplyUseCase, + postReplyUseCase: postReplyUseCase, + patchReplyUseCase: patchReplyUseCase + ) + } } } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Protocol/KeyboardHandleProtocol.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Protocol/KeyboardHandleProtocol.swift new file mode 100644 index 00000000..3d98320b --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Protocol/KeyboardHandleProtocol.swift @@ -0,0 +1,46 @@ +// +// KeyboardHandlable+.swift +// ByeBoo-iOS +// + +import UIKit + +protocol KeyboardHandleProtocol where Self: UIViewController { + func keyboardWillShowOrHide(height: CGFloat, duration: Double) +} + +extension KeyboardHandleProtocol { + func keyboardWillShowOrHide(duration: Double) { + keyboardWillShowOrHide(height: 0, duration: duration) + } +} + +extension KeyboardHandleProtocol { + func addKeyboardObservers() { + NotificationCenter.default.addObserver( + forName: UIResponder.keyboardWillShowNotification, + object: nil, + queue: .main + ) { [weak self] notification in + guard let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect, + let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double + else { return } + self?.keyboardWillShowOrHide(height: keyboardFrame.height, duration: duration) + } + + NotificationCenter.default.addObserver( + forName: UIResponder.keyboardWillHideNotification, + object: nil, + queue: .main + ) { [weak self] notification in + guard let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double + else { return } + self?.keyboardWillShowOrHide(duration: duration) + } + } + + func removeKeyboardObservers() { + NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillShowNotification, object: nil) + NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil) + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/ViewControllerFactory.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/ViewControllerFactory.swift index 18083d63..05799ec3 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/ViewControllerFactory.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/ViewControllerFactory.swift @@ -26,6 +26,15 @@ protocol ViewControllerFactoryProtocol { func makeWriteActiveTypeQuestViewController() -> 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 + func makeByeBooTabBar() -> ByeBooTabBar } final class ViewControllerFactory: ViewControllerFactoryProtocol { @@ -187,7 +196,11 @@ final class ViewControllerFactory: ViewControllerFactoryProtocol { } func makeCommonQuestHistoryViewController() -> CommonQuestHistoryViewController { - .init() + guard let viewModel = DIContainer.shared.resolve(type: CommonQuestHistoryViewModel.self) else { + DIErrorHandle() + fatalError() + } + return .init(viewModel: viewModel) } func makeCommonQuestMyAnswersViewController() -> CommonQuestMyAnswersViewController { @@ -224,6 +237,28 @@ final class ViewControllerFactory: ViewControllerFactoryProtocol { return .init(viewModel: viewModel) } + + func makeNotificationsViewController() -> NotificationsViewController { + guard let viewModel = DIContainer.shared.resolve(type: NotificationsViewModel.self) else { + DIErrorHandle() + fatalError() + } + + return .init(viewModel: viewModel) + } + + func makeByeBooTabBar() -> ByeBooTabBar { + .init() + } + + func makeCommonQuestReplyViewController() -> CommonQuestReplyViewController { + guard let viewModel = DIContainer.shared.resolve(type: CommonQuestReplyViewModel.self) else { + DIErrorHandle() + fatalError() + } + + return .init(viewModel: viewModel) + } } extension ViewControllerFactory { diff --git a/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/Bori/talk.imageset/Contents.json b/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/Bori/talk.imageset/Contents.json new file mode 100644 index 00000000..e56dd212 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/Bori/talk.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "image 1452.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/Bori/talk.imageset/image 1452.svg b/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/Bori/talk.imageset/image 1452.svg new file mode 100644 index 00000000..07cf37f5 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/Bori/talk.imageset/image 1452.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/IconSystem/comment.imageset/Contents.json b/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/IconSystem/comment.imageset/Contents.json new file mode 100644 index 00000000..d4e54c51 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/IconSystem/comment.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "comment.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/IconSystem/comment.imageset/comment.svg b/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/IconSystem/comment.imageset/comment.svg new file mode 100644 index 00000000..44e8b841 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/IconSystem/comment.imageset/comment.svg @@ -0,0 +1,3 @@ + + + diff --git a/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/IconSystem/empty_notice.imageset/Contents.json b/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/IconSystem/empty_notice.imageset/Contents.json new file mode 100644 index 00000000..6ec51c5e --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/IconSystem/empty_notice.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "emptyNotice.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/IconSystem/empty_notice.imageset/emptyNotice.svg b/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/IconSystem/empty_notice.imageset/emptyNotice.svg new file mode 100644 index 00000000..c97a889e --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/IconSystem/empty_notice.imageset/emptyNotice.svg @@ -0,0 +1,3 @@ + + + diff --git a/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/IconSystem/exist_notice.imageset/Contents.json b/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/IconSystem/exist_notice.imageset/Contents.json new file mode 100644 index 00000000..1872a3eb --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/IconSystem/exist_notice.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "existNotice.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/IconSystem/exist_notice.imageset/existNotice.svg b/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/IconSystem/exist_notice.imageset/existNotice.svg new file mode 100644 index 00000000..4b973eda --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/IconSystem/exist_notice.imageset/existNotice.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/IconSystem/heart.imageset/Property 1=heart.svg b/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/IconSystem/heart.imageset/Property 1=heart.svg deleted file mode 100644 index b35689a1..00000000 --- a/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/IconSystem/heart.imageset/Property 1=heart.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/IconSystem/heart_off.imageset/Contents.json b/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/IconSystem/heart_off.imageset/Contents.json new file mode 100644 index 00000000..d022b3c5 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/IconSystem/heart_off.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "heart_off.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/IconSystem/heart_off.imageset/heart_off.svg b/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/IconSystem/heart_off.imageset/heart_off.svg new file mode 100644 index 00000000..6a2d39c7 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/IconSystem/heart_off.imageset/heart_off.svg @@ -0,0 +1,3 @@ + + + diff --git a/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/IconSystem/heart_on.imageset/Contents.json b/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/IconSystem/heart_on.imageset/Contents.json new file mode 100644 index 00000000..b5e8cbaf --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/IconSystem/heart_on.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "heart_on.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/IconSystem/heart_on.imageset/heart_on.svg b/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/IconSystem/heart_on.imageset/heart_on.svg new file mode 100644 index 00000000..70bf32ba --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/IconSystem/heart_on.imageset/heart_on.svg @@ -0,0 +1,3 @@ + + + diff --git a/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/IconSystem/heart.imageset/Contents.json b/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/IconSystem/replyArrow.imageset/Contents.json similarity index 74% rename from ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/IconSystem/heart.imageset/Contents.json rename to ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/IconSystem/replyArrow.imageset/Contents.json index de556b1e..52ca28db 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/IconSystem/heart.imageset/Contents.json +++ b/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/IconSystem/replyArrow.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "Property 1=heart.svg", + "filename" : "icon.svg", "idiom" : "universal" } ], diff --git a/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/IconSystem/replyArrow.imageset/icon.svg b/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/IconSystem/replyArrow.imageset/icon.svg new file mode 100644 index 00000000..4f6764ab --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Resource/Assets.xcassets/IconSystem/replyArrow.imageset/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/ByeBoo-iOS/ByeBoo-iOS/Resource/Info.plist b/ByeBoo-iOS/ByeBoo-iOS/Resource/Info.plist index 616e9eb9..1efb405a 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Resource/Info.plist +++ b/ByeBoo-iOS/ByeBoo-iOS/Resource/Info.plist @@ -2,6 +2,8 @@ + UIDesignRequiresCompatibility + BASE_URL $(BASE_URL) CFBundleURLTypes @@ -14,6 +16,16 @@ kakao$(KAKAO_NATIVE_APP_KEY) + + CFBundleTypeRole + Editor + CFBundleURLName + com.heartz.ByeBoo-iOS + CFBundleURLSchemes + + myapp + + KAKAO_NATIVE_APP_KEY $(KAKAO_NATIVE_APP_KEY) diff --git a/ByeBoo-iOS/ByeBooTests/Feature/Login/AuthTests.swift b/ByeBoo-iOS/ByeBooTests/Feature/Login/AuthTests.swift index 0b783bad..c5b53e1c 100644 --- a/ByeBoo-iOS/ByeBooTests/Feature/Login/AuthTests.swift +++ b/ByeBoo-iOS/ByeBooTests/Feature/Login/AuthTests.swift @@ -15,14 +15,14 @@ struct AuthTests { private let networkService = MockNetworkService(userAPI: MockUserAPI(isAvailable: true)) @Test("🏁 isOnboardingCompleted가 true일 때 ✅ 자동로그인 success") - func isOnboardingCompleted_true__autoLogin_success() async throws { + func isRegistered_true__autoLogin_success() async throws { // Given let authRepository = MockAuthRepository( network: networkService, userDefaultsService: userDefaultsService, keychainService: keychainService ) - let _ = userDefaultsService.save(true, key: .isOnboardingCompleted) + let _ = userDefaultsService.save(true, key: .isRegistered) let autoLoginUseCase = DefaultAutoLoginUseCase(repository: authRepository) // When