From 112d75084eef1aa7efef41773a97e8d49b2f5f6f Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Thu, 11 Sep 2025 21:23:46 +0900 Subject: [PATCH] =?UTF-8?q?feat(character):=20=EC=9D=B8=EA=B8=B0=20?= =?UTF-8?q?=EC=BA=90=EB=A6=AD=ED=84=B0=20=EC=84=B9=EC=85=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Chat/Character/CharacterItemView.swift | 38 ++++++++++++++----- .../Chat/Character/CharacterSectionView.swift | 8 +++- .../Chat/Character/CharacterView.swift | 17 ++++++++- .../Chat/Character/CharacterViewModel.swift | 2 + .../Detail/CharacterDetailView.swift | 4 +- .../Chat/Talk/Room/ChatRoomViewModel.swift | 25 ++++++------ 6 files changed, 66 insertions(+), 28 deletions(-) diff --git a/SodaLive/Sources/Chat/Character/CharacterItemView.swift b/SodaLive/Sources/Chat/Character/CharacterItemView.swift index 8d9e144..7716c36 100644 --- a/SodaLive/Sources/Chat/Character/CharacterItemView.swift +++ b/SodaLive/Sources/Chat/Character/CharacterItemView.swift @@ -12,18 +12,34 @@ struct CharacterItemView: View { let character: Character let size: CGFloat + let rank: Int + let isShowRank: Bool + + private var capHeight: CGFloat { + UIFont(name: Font.preBold.rawValue, size: 72)?.capHeight ?? 72 + } var body: some View { VStack(alignment: .leading, spacing: 4) { - KFImage(URL(string: character.imageUrl)) - .placeholder { Color.gray.opacity(0.2) } - .retry(maxCount: 2, interval: .seconds(1)) - .cancelOnDisappear(true) - .resizable() - .scaledToFill() - .frame(width: size, height: size) - .clipped() - .cornerRadius(12) + ZStack(alignment: .bottomLeading) { + KFImage(URL(string: character.imageUrl)) + .placeholder { Color.gray.opacity(0.2) } + .retry(maxCount: 2, interval: .seconds(1)) + .cancelOnDisappear(true) + .resizable() + .scaledToFill() + .frame(width: size, height: size) + .clipped() + .cornerRadius(12) + + if isShowRank { + Text("\(rank)") + .font(.custom(Font.preBold.rawValue, size: 72)) + .foregroundColor(.white) + .lineLimit(1) + .frame(height: capHeight) + } + } Text(character.name) .font(.custom(Font.preRegular.rawValue, size: 18)) @@ -45,6 +61,8 @@ struct CharacterItemView: View { #Preview { CharacterItemView( character: Character(characterId: 1, name: "찰리", description: "새로운 친구", imageUrl: "https://picsum.photos/300"), - size: 168 + size: 168, + rank: 20, + isShowRank: true ) } diff --git a/SodaLive/Sources/Chat/Character/CharacterSectionView.swift b/SodaLive/Sources/Chat/Character/CharacterSectionView.swift index c9d3803..3836e08 100644 --- a/SodaLive/Sources/Chat/Character/CharacterSectionView.swift +++ b/SodaLive/Sources/Chat/Character/CharacterSectionView.swift @@ -10,6 +10,7 @@ import SwiftUI struct CharacterSectionView: View { let title: String let items: [Character] + let isShowRank: Bool var onTap: (Character) -> Void = { _ in } var body: some View { @@ -25,7 +26,9 @@ struct CharacterSectionView: View { let item = items[idx] CharacterItemView( character: item, - size: screenSize().width * 0.42 + size: screenSize().width * 0.42, + rank: idx + 1, + isShowRank: isShowRank ) .onTapGesture { onTap(item) } } @@ -42,7 +45,8 @@ struct CharacterSectionView: View { items: [ Character(characterId: 1, name: "찰리", description: "새로운 친구", imageUrl: "https://picsum.photos/300"), Character(characterId: 2, name: "데이지", description: "", imageUrl: "https://picsum.photos/300") - ] + ], + isShowRank: true ) .padding() .background(Color.black) diff --git a/SodaLive/Sources/Chat/Character/CharacterView.swift b/SodaLive/Sources/Chat/Character/CharacterView.swift index 5deca42..f8823a6 100644 --- a/SodaLive/Sources/Chat/Character/CharacterView.swift +++ b/SodaLive/Sources/Chat/Character/CharacterView.swift @@ -35,11 +35,23 @@ struct CharacterView: View { } } + // 인기 캐릭터 섹션 + if !viewModel.popularCharacters.isEmpty { + CharacterSectionView( + title: "인기 캐릭터", + items: viewModel.popularCharacters, + isShowRank: true + ) { ch in + onSelectCharacter(ch.characterId) + } + } + // 신규 캐릭터 섹션 if !viewModel.newCharacters.isEmpty { CharacterSectionView( title: "신규 캐릭터", - items: viewModel.newCharacters + items: viewModel.newCharacters, + isShowRank: false ) { ch in onSelectCharacter(ch.characterId) } @@ -52,7 +64,8 @@ struct CharacterView: View { let section = viewModel.curations[idx] CharacterSectionView( title: section.title, - items: section.characters + items: section.characters, + isShowRank: false ) { ch in onSelectCharacter(ch.characterId) } diff --git a/SodaLive/Sources/Chat/Character/CharacterViewModel.swift b/SodaLive/Sources/Chat/Character/CharacterViewModel.swift index ddbc76e..e816aab 100644 --- a/SodaLive/Sources/Chat/Character/CharacterViewModel.swift +++ b/SodaLive/Sources/Chat/Character/CharacterViewModel.swift @@ -13,6 +13,7 @@ final class CharacterViewModel: ObservableObject { // MARK: - Published State @Published private(set) var banners: [CharacterBannerResponse] = [] @Published private(set) var recentCharacters: [RecentCharacter] = [] + @Published private(set) var popularCharacters: [Character] = [] @Published private(set) var newCharacters: [Character] = [] @Published private(set) var curations: [CurationSection] = [] @@ -47,6 +48,7 @@ final class CharacterViewModel: ObservableObject { if let data = decoded.data, decoded.success { self.banners = data.banners self.recentCharacters = data.recentCharacters + self.popularCharacters = data.popularCharacters self.newCharacters = data.newCharacters self.curations = data.curationSections.filter { !$0.characters.isEmpty } } else { diff --git a/SodaLive/Sources/Chat/Character/Detail/CharacterDetailView.swift b/SodaLive/Sources/Chat/Character/Detail/CharacterDetailView.swift index a9301ef..79d9976 100644 --- a/SodaLive/Sources/Chat/Character/Detail/CharacterDetailView.swift +++ b/SodaLive/Sources/Chat/Character/Detail/CharacterDetailView.swift @@ -87,7 +87,9 @@ struct CharacterDetailView: View { description: otherCharacter.tags, imageUrl: otherCharacter.imageUrl ), - size: screenSize().width * 0.42 + size: screenSize().width * 0.42, + rank: 0, + isShowRank: false ) .onTapGesture { // 캐릭터 변경 후 스크롤을 최상단으로 이동 diff --git a/SodaLive/Sources/Chat/Talk/Room/ChatRoomViewModel.swift b/SodaLive/Sources/Chat/Talk/Room/ChatRoomViewModel.swift index 5c2c302..4603567 100644 --- a/SodaLive/Sources/Chat/Talk/Room/ChatRoomViewModel.swift +++ b/SodaLive/Sources/Chat/Talk/Room/ChatRoomViewModel.swift @@ -105,34 +105,33 @@ final class ChatRoomViewModel: ObservableObject { showSendingMessage = true repository.sendMessage(roomId: roomId, message: message) - .sink { result in - switch result { + .receive(on: DispatchQueue.main) + .sink { [weak self] completion in + guard let self = self else { return } + switch completion { case .finished: DEBUG_LOG("finish") case .failure(let error): + self.showSendingMessage = false // 실패 시 복구 + self.errorMessage = error.localizedDescription + self.isShowPopup = true ERROR_LOG(error.localizedDescription) } - } receiveValue: { [unowned self] response in + } receiveValue: { [weak self] response in + guard let self = self else { return } let responseData = response.data - do { let jsonDecoder = JSONDecoder() 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) } else { - if let message = decoded.message { - self.errorMessage = message - } else { - self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." - } - + self.errorMessage = decoded.message ?? + "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." self.isShowPopup = true } - - self.showSendingMessage = false + self.showSendingMessage = false // 성공 시 종료 } catch { self.showSendingMessage = false self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."