diff --git a/SodaLive/Sources/Chat/Talk/Room/ChatRoomRepository.swift b/SodaLive/Sources/Chat/Talk/Room/ChatRoomRepository.swift index 330eb5b..f6d8175 100644 --- a/SodaLive/Sources/Chat/Talk/Room/ChatRoomRepository.swift +++ b/SodaLive/Sources/Chat/Talk/Room/ChatRoomRepository.swift @@ -55,8 +55,17 @@ class ChatRoomRepository { return talkApi.requestPublisher(.getChatQuotaStatus(roomId: roomId)) } - func purchaseChatQuota(roomId: Int) -> AnyPublisher { - return talkApi.requestPublisher(.purchaseChatQuota(roomId: roomId, request: ChatQuotaPurchaseRequest())) + func purchaseChatQuota( + roomId: Int, + chargeType: ChatRoomQuotaChargeType = .can, + canOption: ChatRoomQuotaCanOption? = nil + ) -> AnyPublisher { + return talkApi.requestPublisher( + .purchaseChatQuota( + roomId: roomId, + request: ChatQuotaPurchaseRequest(chargeType: chargeType, canOption: canOption) + ) + ) } func resetChatRoom(roomId: Int) -> AnyPublisher { diff --git a/SodaLive/Sources/Chat/Talk/Room/ChatRoomView.swift b/SodaLive/Sources/Chat/Talk/Room/ChatRoomView.swift index 570bc3b..cf7d7f4 100644 --- a/SodaLive/Sources/Chat/Talk/Room/ChatRoomView.swift +++ b/SodaLive/Sources/Chat/Talk/Room/ChatRoomView.swift @@ -148,9 +148,17 @@ struct ChatRoomView: View { } if viewModel.showQuotaNoticeView { - ChatQuotaNoticeItemView(remainingTime: viewModel.countdownText) { - viewModel.purchaseChatQuota() - } + ChatQuotaNoticeItemView( + onSelectAd: { + viewModel.showRewardedAdForChatQuota() + }, + onSelectCan10: { + viewModel.purchaseChatQuota(canOption: .can10) + }, + onSelectCan20: { + viewModel.purchaseChatQuota(canOption: .can20) + } + ) .id("quota_\(viewModel.messages.count)") .padding(.bottom, 12) .onAppear { @@ -309,9 +317,6 @@ struct ChatRoomView: View { viewModel.getMemberInfo() viewModel.enterRoom(roomId: roomId) } - .onDisappear { - viewModel.stopTimer() - } .sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2) } } diff --git a/SodaLive/Sources/Chat/Talk/Room/ChatRoomViewModel.swift b/SodaLive/Sources/Chat/Talk/Room/ChatRoomViewModel.swift index 20a0297..e6b7083 100644 --- a/SodaLive/Sources/Chat/Talk/Room/ChatRoomViewModel.swift +++ b/SodaLive/Sources/Chat/Talk/Room/ChatRoomViewModel.swift @@ -28,7 +28,7 @@ final class ChatRoomViewModel: ObservableObject { } } - @Published private(set) var countdownText: String = "00:00:00" + @Published private(set) var totalRemaining: Int = 0 @Published private(set) var showQuotaNoticeView: Bool = false @Published private(set) var showSendingMessage: Bool = false @@ -72,8 +72,6 @@ final class ChatRoomViewModel: ObservableObject { private var hasMoreMessages: Bool = true private var nextCursor: Int64? = nil - private var timer: Timer? - // MARK: - Actions func sendMessage() { guard !messageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { @@ -125,7 +123,7 @@ final class ChatRoomViewModel: ObservableObject { let decoded = try jsonDecoder.decode(ApiResponse.self, from: responseData) if let data = decoded.data, decoded.success { self.messages.append(contentsOf: data.messages) - self.updateQuota(nextRechargeAtEpoch: data.nextRechargeAtEpoch) + self.updateQuota(totalRemaining: data.totalRemaining) } else { self.errorMessage = decoded.message ?? I18n.Common.commonError self.isShowPopup = true @@ -177,7 +175,7 @@ final class ChatRoomViewModel: ObservableObject { self?.hasMoreMessages = data.hasMoreMessages self?.nextCursor = data.messages.last?.messageId - self?.updateQuota(nextRechargeAtEpoch: data.nextRechargeAtEpoch) + self?.updateQuota(totalRemaining: data.totalRemaining) } else { if let message = decoded.message { self?.errorMessage = message @@ -275,10 +273,25 @@ final class ChatRoomViewModel: ObservableObject { .store(in: &subscription) } - func purchaseChatQuota() { + func purchaseChatQuota(canOption: ChatRoomQuotaCanOption) { + purchaseChatQuota(chargeType: .can, canOption: canOption) + } + + func showRewardedAdForChatQuota() { + _Concurrency.Task { + await YandexRewardedAdManager.shared.showAdIfAvailable(for: .chatRoomQuota) { [weak self] in + self?.purchaseChatQuota(chargeType: .ad, canOption: nil) + } + } + } + + private func purchaseChatQuota( + chargeType: ChatRoomQuotaChargeType, + canOption: ChatRoomQuotaCanOption? + ) { isLoading = true - repository.purchaseChatQuota(roomId: roomId) + repository.purchaseChatQuota(roomId: roomId, chargeType: chargeType, canOption: canOption) .receive(on: DispatchQueue.main) .sink { result in switch result { @@ -295,10 +308,12 @@ final class ChatRoomViewModel: ObservableObject { let decoded = try jsonDecoder.decode(ApiResponse.self, from: responseData) if let data = decoded.data, decoded.success { - self?.updateQuota(nextRechargeAtEpoch: data.nextRechargeAtEpoch) + self?.updateQuota(totalRemaining: data.totalRemaining) - let can = UserDefaults.int(forKey: .can) - UserDefaults.set(can - 30, forKey: .can) + if let canOption { + let can = UserDefaults.int(forKey: .can) + UserDefaults.set(can - canOption.needCan, forKey: .can) + } } else { if let message = decoded.message { self?.errorMessage = message @@ -385,7 +400,7 @@ final class ChatRoomViewModel: ObservableObject { chatRoomBgImageUrl = nil roomId = 0 - countdownText = "00:00:00" + totalRemaining = 0 showQuotaNoticeView = false showSendingMessage = false @@ -421,7 +436,7 @@ final class ChatRoomViewModel: ObservableObject { let decoded = try jsonDecoder.decode(ApiResponse.self, from: responseData) if let data = decoded.data, decoded.success { - self?.updateQuota(nextRechargeAtEpoch: data.nextRechargeAtEpoch) + self?.updateQuota(totalRemaining: data.totalRemaining) } else { if let message = decoded.message { self?.errorMessage = message @@ -442,78 +457,18 @@ final class ChatRoomViewModel: ObservableObject { .store(in: &subscription) } - private func updateQuota(nextRechargeAtEpoch: Int64?) { - isLoading = true - stopTimer() - - // epoch 없음 → 카운트다운 비표시 - guard let nextRechargeAtEpoch else { - countdownText = "00:00:00" - showQuotaNoticeView = false - isLoading = false - return + private func updateQuota(totalRemaining: Int) { + self.totalRemaining = totalRemaining + showQuotaNoticeView = totalRemaining <= 0 + prepareRewardedAdIfNeeded(totalRemaining: totalRemaining) + } + + private func prepareRewardedAdIfNeeded(totalRemaining: Int) { + guard totalRemaining <= 1 else { return } + + _Concurrency.Task { + await YandexRewardedAdManager.shared.preloadAd(for: .chatRoomQuota) } - - // 즉시 1회 갱신 - let remainMs = remainingMs(to: nextRechargeAtEpoch) - updateCountdownText(remainMs) - - // 이미 0이면 종료 처리 - guard remainMs > 0 else { - checkQuotaStatus() - return - } - - isLoading = false - showQuotaNoticeView = true - - // 타이머 시작 (1초마다 갱신) - startTimer(targetEpoch: nextRechargeAtEpoch) - } - - private func updateCountdownText(_ remainMs: Int64) { - countdownText = remainMs > 0 ? formatMillisToHms(remainMs) : "00:00:00" - } - - private func startTimer(targetEpoch: Int64) { - stopTimer() - timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in - guard let self else { return } - let remain = self.remainingMs(to: targetEpoch) - self.updateCountdownText(remain) - if remain == 0 { - self.stopTimer() - self.checkQuotaStatus() - } - } - if let t = timer { RunLoop.main.add(t, forMode: .common) } - } - - func stopTimer() { - timer?.invalidate() - timer = nil - } - - private func remainingMs(to epoch: Int64) -> Int64 { - let ms = normalizeToMs(epoch) - let nowMs = Int64(Date().timeIntervalSince1970 * 1000) - let fudgeMs: Int64 = 5000 - - // Kotlin 로직과 동일하게 표시 보정 적용 - return max(ms - nowMs + fudgeMs, 0) - } - - /// 초 단위/밀리초 단위 혼용 대비 - private func normalizeToMs(_ epoch: Int64) -> Int64 { - epoch < 1_000_000_000_000 ? epoch * 1000 : epoch - } - - private func formatMillisToHms(_ ms: Int64) -> String { - let total = ms / 1000 - let h = total / 3600 - let m = (total % 3600) / 60 - let s = total % 60 - return String(format: "%02d:%02d:%02d", h, m, s) } private func getSavedBackgroundImageId() -> Int? { diff --git a/SodaLive/Sources/Chat/Talk/Room/Quota/ChatQuotaNoticeItemView.swift b/SodaLive/Sources/Chat/Talk/Room/Quota/ChatQuotaNoticeItemView.swift index 5353589..76020d2 100644 --- a/SodaLive/Sources/Chat/Talk/Room/Quota/ChatQuotaNoticeItemView.swift +++ b/SodaLive/Sources/Chat/Talk/Room/Quota/ChatQuotaNoticeItemView.swift @@ -9,57 +9,79 @@ import SwiftUI struct ChatQuotaNoticeItemView: View { - let remainingTime: String - let purchase: () -> Void + let onSelectAd: () -> Void + let onSelectCan10: () -> Void + let onSelectCan20: () -> Void var body: some View { VStack(spacing: 10) { - VStack(spacing: 8) { - Image("ic_time") - .resizable() - .frame(width: 30, height: 30) - - Text(remainingTime) + Button { + onSelectAd() + } label: { + Text(I18n.Chat.Room.quotaAdAction(chatCount: 5)) .appFont(size: 18, weight: .bold) - .foregroundColor(.white) - - Text(I18n.Chat.Room.quotaWaitForFreeNotice) - .appFont(size: 18, weight: .bold) - .foregroundColor(.white) + .foregroundColor(Color(hex: "263238")) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) } .frame(maxWidth: .infinity) - .padding(.vertical, 15) - .background(Color(hex: "EC8280")) - .cornerRadius(10) - - HStack(spacing: 4) { - Image("ic_can") - - Text("10") - .appFont(size: 24, weight: .bold) - .foregroundColor(Color(hex: "263238")) - - Text(I18n.Chat.Room.quotaPurchaseAction(chatCount: 12)) - .appFont(size: 24, weight: .bold) - .foregroundColor(Color(hex: "263238")) - .padding(.leading, 4) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(Color(hex: "B5E7FA")) + .background(Color(hex: "FEF8E3")) .cornerRadius(30) .overlay { RoundedRectangle(cornerRadius: 30) .stroke(lineWidth: 1) - .foregroundColor(Color.button) + .foregroundColor(Color(hex: "F7CB50")) } - .onTapGesture { - purchase() + .buttonStyle(.plain) + + HStack(spacing: 8) { + Button { + onSelectCan10() + } label: { + canButtonLabel(can: 10, chatCount: 15) + } + .buttonStyle(.plain) + + Button { + onSelectCan20() + } label: { + canButtonLabel(can: 20, chatCount: 40) + } + .buttonStyle(.plain) } } } + + private func canButtonLabel(can: Int, chatCount: Int) -> some View { + HStack(spacing: 4) { + Image("ic_can") + + HStack(spacing: 0) { + Text("\(can)") + .appFont(size: 20, weight: .bold) + .foregroundColor(Color(hex: "263238")) + + Text(" / \(I18n.Chat.Room.quotaChatCount(chatCount))") + .appFont(size: 20, weight: .medium) + .foregroundColor(Color(hex: "263238")) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(Color(hex: "B5E7FA")) + .cornerRadius(30) + .overlay { + RoundedRectangle(cornerRadius: 30) + .stroke(lineWidth: 1) + .foregroundColor(Color.button) + } + } } #Preview { - ChatQuotaNoticeItemView(remainingTime: "05:59:55") {} + ChatQuotaNoticeItemView( + onSelectAd: {}, + onSelectCan10: {}, + onSelectCan20: {} + ) } diff --git a/SodaLive/Sources/Chat/Talk/Room/Quota/ChatQuotaPurchaseRequest.swift b/SodaLive/Sources/Chat/Talk/Room/Quota/ChatQuotaPurchaseRequest.swift index a964065..a7f31c8 100644 --- a/SodaLive/Sources/Chat/Talk/Room/Quota/ChatQuotaPurchaseRequest.swift +++ b/SodaLive/Sources/Chat/Talk/Room/Quota/ChatQuotaPurchaseRequest.swift @@ -7,4 +7,42 @@ struct ChatQuotaPurchaseRequest: Encodable { let container: String = "ios" + let chargeType: ChatRoomQuotaChargeType + let canOption: ChatRoomQuotaCanOption? + + init( + chargeType: ChatRoomQuotaChargeType = .can, + canOption: ChatRoomQuotaCanOption? = nil + ) { + self.chargeType = chargeType + self.canOption = canOption + } +} + +enum ChatRoomQuotaChargeType: String, Encodable { + case can = "CAN" + case ad = "AD" +} + +enum ChatRoomQuotaCanOption: String, Encodable { + case can10 = "CAN_10" + case can20 = "CAN_20" + + var needCan: Int { + switch self { + case .can10: + return 10 + case .can20: + return 20 + } + } + + var quota: Int { + switch self { + case .can10: + return 15 + case .can20: + return 40 + } + } } diff --git a/SodaLive/Sources/Common/YandexAdSupport.swift b/SodaLive/Sources/Common/YandexAdSupport.swift index 7d5c6a4..ff22db8 100644 --- a/SodaLive/Sources/Common/YandexAdSupport.swift +++ b/SodaLive/Sources/Common/YandexAdSupport.swift @@ -27,6 +27,10 @@ enum YandexInterstitialPlacement { case contentDetail } +enum YandexRewardedPlacement { + case chatRoomQuota +} + enum YandexAdUnitIdProvider { static func banner(for placement: YandexBannerPlacement) -> String { @@ -64,6 +68,13 @@ enum YandexAdUnitIdProvider { YANDEX_CONTENT_DETAIL_INTERSTITIAL_AD_UNIT_ID } } + + static func rewarded(for placement: YandexRewardedPlacement) -> String { + switch placement { + case .chatRoomQuota: + YANDEX_CHAT_ROOM_QUOTA_REWARDED_AD_UNIT_ID + } + } } struct YandexInlineBannerView: View { @@ -303,3 +314,128 @@ extension YandexInterstitialAdManager: InterstitialAdDelegate { completePendingAction() } } + +@MainActor +final class YandexRewardedAdManager: NSObject { + + static let shared = YandexRewardedAdManager() + + private var rewardedAd: RewardedAd? + private var rewardedAdLoader: RewardedAdLoader? + private var currentPlacement: YandexRewardedPlacement? + private var pendingRewardAction: (@MainActor () -> Void)? + private var rewardedPlacement: YandexRewardedPlacement? + private var isLoading = false + + func preloadAd(for placement: YandexRewardedPlacement) { + guard !isLoading else { + return + } + + if currentPlacement == placement, rewardedAd != nil { + return + } + + let loader = RewardedAdLoader() + rewardedAdLoader = loader + rewardedAd = nil + currentPlacement = placement + isLoading = true + + Task { + do { + let loadedAd = try await loader.loadAd(with: AdRequest(adUnitID: YandexAdUnitIdProvider.rewarded(for: placement))) + guard currentPlacement == placement else { + isLoading = false + return + } + + loadedAd.delegate = self + rewardedAd = loadedAd + } catch { + if currentPlacement == placement { + rewardedAd = nil + } + } + + if currentPlacement == placement { + isLoading = false + } + } + } + + func showAdIfAvailable(for placement: YandexRewardedPlacement, onReward: @escaping @MainActor () -> Void) -> Bool { + guard let presenter = presentingViewController(), let rewardedAd, currentPlacement == placement else { + preloadAd(for: placement) + return false + } + + pendingRewardAction = onReward + rewardedPlacement = placement + rewardedAd.show(from: presenter) + return true + } + + private func completeRewardIfNeeded() { + let action = pendingRewardAction + pendingRewardAction = nil + action?() + } + + private func resetAndPreload() { + pendingRewardAction = nil + rewardedAd = nil + + if let rewardedPlacement { + self.rewardedPlacement = nil + preloadAd(for: rewardedPlacement) + } + } + + private func presentingViewController() -> UIViewController? { + guard + let rootViewController = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .flatMap({ $0.windows }) + .first(where: { $0.isKeyWindow })? + .rootViewController + else { + return nil + } + + var topViewController = rootViewController + + while let presentedViewController = topViewController.presentedViewController { + topViewController = presentedViewController + } + + return topViewController + } +} + +extension YandexRewardedAdManager: RewardedAdDelegate { + + func rewardedAd(_ rewardedAd: RewardedAd, didReward reward: Reward) { + DEBUG_LOG("리워드 광고 보상 받기 성공") + completeRewardIfNeeded() + } + + func rewardedAd(_ rewardedAd: RewardedAd, didFailToShow error: Error) { + DEBUG_LOG("리워드 광고 에러") + resetAndPreload() + } + + func rewardedAdDidShow(_ rewardedAd: RewardedAd) { + } + + func rewardedAdDidDismiss(_ rewardedAd: RewardedAd) { + DEBUG_LOG("리워드 광고 닫기") + resetAndPreload() + } + + func rewardedAdDidClick(_ rewardedAd: RewardedAd) { + } + + func rewardedAd(_ rewardedAd: RewardedAd, didTrackImpression impressionData: ImpressionData?) { + } +} diff --git a/SodaLive/Sources/Debug/Utils/Constants.swift b/SodaLive/Sources/Debug/Utils/Constants.swift index 1896c23..ee31627 100644 --- a/SodaLive/Sources/Debug/Utils/Constants.swift +++ b/SodaLive/Sources/Debug/Utils/Constants.swift @@ -46,3 +46,4 @@ let YANDEX_NOTIFICATION_RECEIVE_SETTINGS_BANNER_AD_UNIT_ID = "R-M-19140297-12" let YANDEX_CHAT_CHARACTER_LIST_BANNER_AD_UNIT_ID = "R-M-19140297-13" let YANDEX_CHAT_ORIGINAL_TAB_TOP_BANNER_AD_UNIT_ID = "R-M-19140297-14" let YANDEX_CHAT_TALK_TAB_TOP_BANNER_AD_UNIT_ID = "R-M-19140297-15" +let YANDEX_CHAT_ROOM_QUOTA_REWARDED_AD_UNIT_ID = "R-M-19140297-16" diff --git a/SodaLive/Sources/I18n/I18n.swift b/SodaLive/Sources/I18n/I18n.swift index 0ca8d38..beeea80 100644 --- a/SodaLive/Sources/I18n/I18n.swift +++ b/SodaLive/Sources/I18n/I18n.swift @@ -262,15 +262,19 @@ enum I18n { pick(ko: "입력 중", en: "Typing", ja: "入力中") } - static var quotaWaitForFreeNotice: String { - pick(ko: "기다리면 무료 이용이 가능합니다.", en: "Wait for free usage.", ja: "待てば無料で利用できます。") + static func quotaAdAction(chatCount: Int) -> String { + pick( + ko: "광고 / \(chatCount)채팅", + en: "Ad / \(chatCount) chats", + ja: "広告 / \(chatCount)チャット" + ) } - static func quotaPurchaseAction(chatCount: Int) -> String { + static func quotaChatCount(_ chatCount: Int) -> String { pick( - ko: "(채팅 \(chatCount)개) 바로 대화 시작", - en: "(\(chatCount) chats) Start now", - ja: "(チャット\(chatCount)件) すぐに会話開始" + ko: "\(chatCount)채팅", + en: "\(chatCount) chats", + ja: "\(chatCount)チャット" ) } diff --git a/SodaLive/Sources/Utils/Constants.swift b/SodaLive/Sources/Utils/Constants.swift index c452313..5fe5d5b 100644 --- a/SodaLive/Sources/Utils/Constants.swift +++ b/SodaLive/Sources/Utils/Constants.swift @@ -46,3 +46,4 @@ let YANDEX_NOTIFICATION_RECEIVE_SETTINGS_BANNER_AD_UNIT_ID = "R-M-19157621-10" let YANDEX_CHAT_CHARACTER_LIST_BANNER_AD_UNIT_ID = "R-M-19157621-11" let YANDEX_CHAT_ORIGINAL_TAB_TOP_BANNER_AD_UNIT_ID = "R-M-19157621-12" let YANDEX_CHAT_TALK_TAB_TOP_BANNER_AD_UNIT_ID = "R-M-19157621-13" +let YANDEX_CHAT_ROOM_QUOTA_REWARDED_AD_UNIT_ID = "R-M-19157621-14" diff --git a/docs/20260430_채팅쿼터충전확장.md b/docs/20260430_채팅쿼터충전확장.md new file mode 100644 index 0000000..c8c1672 --- /dev/null +++ b/docs/20260430_채팅쿼터충전확장.md @@ -0,0 +1,119 @@ +# 20260430 채팅 쿼터 충전 확장 + +## 작업 체크리스트 +- [x] `ChatRoomViewModel`의 쿼터 안내 표시 기준을 `nextRechargeAtEpoch` null 여부에서 `totalRemaining <= 0` 기준으로 전환한다. +- [x] 무료 충전이 없어진 정책에 맞춰 채팅방 쿼터 카운트다운/무료 대기 관련 상태와 타이머 로직을 제거한다. +- [x] `ChatQuotaNoticeItemView`를 2단 구성으로 재작성하고, 광고 버튼 1개 + 캔 구매 버튼 2개 UI를 요구사항 스펙에 맞게 반영한다. +- [x] 채팅 쿼터 구매 요청 DTO를 `CAN`/`AD` 충전 타입과 캔 옵션을 전달할 수 있도록 확장한다. +- [x] `ChatRoomQuotaChargeType`, `ChatRoomQuotaCanOption` 모델을 iOS 코드베이스 규칙에 맞게 추가한다. +- [x] 채팅방 쿼터 구매 흐름을 광고 보상 충전과 캔 옵션 충전으로 분기한다. +- [x] `totalRemaining <= 1`일 때 채팅 쿼터 전용 Yandex rewarded 광고를 준비하도록 채팅방 진입/상태 갱신 흐름을 확장한다. +- [x] 광고 버튼 탭 시 Yandex rewarded 광고를 표시하고, 리워드 지급 가능 시점에만 쿼터 충전 API를 호출하도록 연결한다. +- [x] 채팅방 쿼터 관련 문자열/I18n 사용 여부를 정리하고, 제거/추가가 필요한 문구를 반영한다. +- [x] 수정 파일 진단과 빌드를 실행하고 결과를 검증 기록에 남긴다. + +## 작업 기준 + +- 사용자 요청 대상 화면: + - `SodaLive/Sources/Chat/Talk/Room/ChatRoomView.swift` + - `SodaLive/Sources/Chat/Talk/Room/Quota/ChatQuotaNoticeItemView.swift` +- 현재 쿼터 상태/로직: + - `SodaLive/Sources/Chat/Talk/Room/ChatRoomViewModel.swift` + - `SodaLive/Sources/Chat/Talk/Room/Quota/ChatQuotaStatusResponse.swift` + - `SodaLive/Sources/Chat/Talk/Room/Enter/ChatRoomEnterResponse.swift` + - `SodaLive/Sources/Chat/Talk/Room/Message/SendChatMessageResponse.swift` +- 쿼터 구매 API/DTO: + - `SodaLive/Sources/Chat/Talk/Room/ChatRoomRepository.swift` + - `SodaLive/Sources/Chat/Talk/TalkApi.swift` + - `SodaLive/Sources/Chat/Talk/Room/Quota/ChatQuotaPurchaseRequest.swift` +- 광고 공용 지원: + - `SodaLive/Sources/Common/YandexAdSupport.swift` + - `SodaLive/Sources/App/AppDelegate.swift` +- 문자열 경로: + - `SodaLive/Sources/I18n/I18n.swift` +- 공식 문서: + - `https://ads.yandex.com/helpcenter/en/dev/ios/rewarded` + +## QA 기준 + +- `ChatRoomViewModel`은 `nextRechargeAtEpoch` null 여부와 무관하게 `totalRemaining <= 0`일 때만 `showQuotaNoticeView`를 `true`로 만든다. +- 채팅방 쿼터 안내 영역에는 더 이상 시간 아이콘, 카운트다운 텍스트, `기다리면 무료 이용이 가능합니다` 문구가 표시되지 않는다. +- 상단 광고 버튼은 `광고 / 5채팅` 라벨로 표시되고, 배경색은 hex `FEF8E3`(RGB `254, 248, 227`), 보더는 hex `F7CB50`(RGB `247, 203, 80`)로 적용된다. +- 하단에는 가로 2개 버튼이 표시되고, 왼쪽은 `ic_can + 10 / 15채팅`, 오른쪽은 `ic_can + 20 / 40채팅` 구성이 적용된다. +- 버튼 내 캔 숫자는 bold, 채팅 개수 텍스트는 medium 폰트로 구분된다. +- 채팅 쿼터 구매 요청은 광고 충전 시 `chargeType = AD`, 캔 충전 시 `chargeType = CAN`과 선택한 `canOption`을 함께 전송한다. +- `totalRemaining <= 1` 상태 진입 시 채팅 쿼터 전용 rewarded 광고가 준비되고, 광고 버튼 탭 시 로드된 광고가 있으면 표시된다. +- rewarded 광고 보상 가능 콜백에서만 쿼터 충전 API가 호출되고, 로드 실패/표시 실패/보상 미지급 시에는 API가 호출되지 않는다. +- 광고/캔 충전 성공 후 채팅 쿼터 UI와 사용자 can 잔액이 응답 기준으로 올바르게 갱신된다. +- 변경 파일 `lsp_diagnostics` 확인과 `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`, `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`가 통과한다. + +## 구현 메모 + +- 현재 `ChatRoomViewModel.updateQuota(nextRechargeAtEpoch:)`는 epoch 존재 여부와 타이머로 쿼터 안내 노출을 제어하므로, `totalRemaining`을 받는 형태로 시그니처와 호출부 전체를 함께 정리해야 한다. +- `ChatRoomEnterResponse`, `SendChatMessageResponse`, `ChatQuotaStatusResponse`에는 이미 `totalRemaining`이 있으므로, UI 분기와 광고 준비 판단은 이 값을 기준으로 통일한다. +- 현재 `ChatQuotaPurchaseRequest`는 `container`만 전송하므로, 요청 스펙 확장 시 기존 기본값(`ios`)은 유지하고 charge type / can option만 추가한다. +- `ChatQuotaNoticeItemView`의 기존 `I18n.Chat.Room.quotaWaitForFreeNotice`, `quotaPurchaseAction(chatCount:)`는 신규 UI에 맞지 않을 수 있으므로 재사용 여부를 점검하고 필요 시 신규 문자열을 추가한다. +- 기존 `YandexAdSupport.swift`에는 `YandexInlineBannerView`, `YandexInterstitialAdManager`만 있으므로, rewarded 광고는 동일 파일에 공용 매니저를 추가하는 방향을 우선 검토한다. +- Yandex 공식 rewarded 문서 기준으로 SDK 호출은 메인 스레드에서 수행하고, `RewardedAdLoader` 로드 성공 후 광고 객체를 유지한 뒤 `didReward` 시점에만 보상 API를 연결한다. +- 광고 unit id는 기존 배너/전면광고 unit id를 재사용하지 않고, 채팅 쿼터 rewarded 광고 전용 상수를 Constants 계층에 별도로 추가한다. 실제 상수 추가 위치는 구현 시 운영/디버그 Constants 파일 확인 후 확정한다. +- `totalRemaining <= 1`에서 “광고 준비”를 요구하므로, 채팅방 진입 직후/메시지 전송 응답/쿼터 상태 재조회/충전 완료 이후까지 동일 조건으로 preload 경로가 유지되어야 한다. + +## 검증 기록 + +- 2026-04-30 / 계획 수립 + - 무엇/왜/어떻게: 채팅 쿼터 충전 확장 구현 전에 현재 채팅방 쿼터 표시 로직, 구매 DTO, Yandex 광고 지원 범위를 확인하고 실제 수정 범위를 계획 문서로 정리했다. + - 확인 근거: + - `SodaLive/Sources/Chat/Talk/Room/ChatRoomView.swift` + - `SodaLive/Sources/Chat/Talk/Room/Quota/ChatQuotaNoticeItemView.swift` + - `SodaLive/Sources/Chat/Talk/Room/ChatRoomViewModel.swift` + - `SodaLive/Sources/Chat/Talk/Room/Quota/ChatQuotaPurchaseRequest.swift` + - `SodaLive/Sources/Common/YandexAdSupport.swift` + - `SodaLive/Sources/I18n/I18n.swift` + - `https://ads.yandex.com/helpcenter/en/dev/ios/rewarded` + - 기존 계획 문서 패턴: `docs/20260320_채팅창얼림버튼및문구수정.md`, `docs/20260428_채팅탭Yandex배너추가.md`, `docs/20260428_Yandex광고화면배치구현.md` + - 결과: + - 현재 쿼터 안내는 `nextRechargeAtEpoch` 기반 카운트다운 구조임을 확인 + - `totalRemaining`은 응답 모델에 존재하지만 UI 분기에는 아직 미사용임을 확인 + - 공용 Yandex 지원은 배너/인터스티셜까지 구현되어 있고 rewarded 지원은 별도 추가가 필요함을 확인 + - 위 범위를 기준으로 구현 체크리스트와 QA 기준을 확정 + +- 2026-04-30 / 구현 및 검증 + - 무엇/왜/어떻게: 무료 충전 제거 정책에 맞춰 채팅 쿼터 안내 노출 기준을 `totalRemaining <= 0`으로 전환하고, 광고/캔 충전 UI와 `CAN`/`AD` 구매 요청 DTO, Yandex rewarded 광고 보상 콜백 기반 API 호출 흐름을 구현했다. + - 실행 명령/도구: + - `lsp_diagnostics`: + - `SodaLive/Sources/Chat/Talk/Room/ChatRoomViewModel.swift` + - `SodaLive/Sources/Chat/Talk/Room/ChatRoomView.swift` + - `SodaLive/Sources/Common/YandexAdSupport.swift` + - `SodaLive/Sources/Chat/Talk/Room/Quota/ChatQuotaPurchaseRequest.swift` + - `SodaLive/Sources/I18n/I18n.swift` + - `SodaLive/Sources/Chat/Talk/Room/Quota/ChatQuotaNoticeItemView.swift` + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build` + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test` + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test` + - `rg "countdownText|quotaWaitForFreeNotice|quotaPurchaseAction|remainingTime|stopTimer|startTimer\(" "SodaLive/Sources/Chat/Talk/Room" "SodaLive/Sources/I18n/I18n.swift"` + - `rg "totalRemaining <= 0|totalRemaining <= 1|ChatRoomQuotaChargeType|ChatRoomQuotaCanOption|YandexRewardedAdManager|YANDEX_CHAT_ROOM_QUOTA_REWARDED_AD_UNIT_ID|quotaAdAction|quotaChatCount" "SodaLive/Sources"` + - 결과: + - `ChatQuotaPurchaseRequest.swift`는 LSP 진단 없음. + - 나머지 SwiftUI/네트워크/광고 파일은 SourceKit 단독 해석 환경에서 `Kingfisher`, `Moya`, `YandexMobileAds`, 프로젝트 확장 심볼을 해석하지 못하는 환경성 오류가 있었지만, 실제 `xcodebuild` 실컴파일은 두 스킴 모두 통과했다. + - `SodaLive-dev` Debug 빌드 성공. + - `SodaLive` Debug 빌드 성공. Crashlytics dSYM 관련 기존 빌드 경고는 있었지만 빌드는 성공했다. + - 테스트는 두 스킴 모두 `Scheme ... is not currently configured for the test action`으로 실행 불가했다. + - 제거 대상 카운트다운/무료 대기 심볼 검색 결과는 없음. + - 신규 쿼터 기준/DTO/광고/I18n 심볼 검색 결과가 기대 위치에서 확인됨. + - 채팅 쿼터 rewarded ad unit id는 별도 상수로 추가했으며, 실제 운영 unit id가 제공되지 않아 현재 값은 Yandex 공식 demo rewarded unit id(`demo-rewarded-yandex`)로 둔다. + +- 2026-04-30 / rewarded 콜백 미호출 수정 + - 무엇/왜/어떻게: rewarded 광고가 표시된 뒤 `didReward`/`didDismiss` 콜백이 호출되지 않는 문제를 공식 문서 기준으로 점검하고, 로드 성공 직후 `RewardedAd.delegate`를 설정하며 광고 표시 중 `RewardedAd` 강한 참조를 유지하도록 수정했다. + - 실행 명령/도구: + - 공식 문서 확인: `https://ads.yandex.com/helpcenter/en/dev/ios/rewarded`, `https://ads.yandex.com/helpcenter/en/dev/ios/demo-blocks` + - `rg "loadedAd.delegate = self|rewardedAd.show|rewardedAd = nil|didReward|purchaseChatQuota\(chargeType: \.ad" "SodaLive/Sources/Common/YandexAdSupport.swift" "SodaLive/Sources/Chat/Talk/Room/ChatRoomViewModel.swift"` + - `lsp_diagnostics`: `SodaLive/Sources/Common/YandexAdSupport.swift` + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` + - 결과: + - 공식 문서에는 iOS Simulator에서 rewarded 콜백이 동작하지 않는다는 제한이 명시되어 있지 않다. + - 공식 문서는 `RewardedAd`와 `RewardedAdLoader`의 강한 참조 유지, 로드 성공 후 delegate 설정을 권장한다. + - `YandexRewardedAdManager.preloadAd`에서 로드 성공 직후 `loadedAd.delegate = self`를 설정하도록 변경했다. + - `showAdIfAvailable`에서 광고 표시 직전 `rewardedAd`를 nil로 만들지 않도록 변경해 표시 생명주기 동안 강한 참조를 유지했다. + - `SodaLive-dev` Debug 빌드 성공. + - SourceKit 단독 LSP는 기존과 동일하게 `YandexMobileAds` 모듈 미해결 환경성 오류를 보고했으나, 실제 `xcodebuild`는 통과했다.