feat(chat): 채팅 쿼터 광고 충전을 추가한다
This commit is contained in:
@@ -55,8 +55,17 @@ class ChatRoomRepository {
|
||||
return talkApi.requestPublisher(.getChatQuotaStatus(roomId: roomId))
|
||||
}
|
||||
|
||||
func purchaseChatQuota(roomId: Int) -> AnyPublisher<Response, MoyaError> {
|
||||
return talkApi.requestPublisher(.purchaseChatQuota(roomId: roomId, request: ChatQuotaPurchaseRequest()))
|
||||
func purchaseChatQuota(
|
||||
roomId: Int,
|
||||
chargeType: ChatRoomQuotaChargeType = .can,
|
||||
canOption: ChatRoomQuotaCanOption? = nil
|
||||
) -> AnyPublisher<Response, MoyaError> {
|
||||
return talkApi.requestPublisher(
|
||||
.purchaseChatQuota(
|
||||
roomId: roomId,
|
||||
request: ChatQuotaPurchaseRequest(chargeType: chargeType, canOption: canOption)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func resetChatRoom(roomId: Int) -> AnyPublisher<Response, MoyaError> {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<SendChatMessageResponse>.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<ChatQuotaStatusResponse>.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<ChatQuotaStatusResponse>.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? {
|
||||
|
||||
@@ -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: {}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user