feat(character): 인기 캐릭터 섹션 추가
This commit is contained in:
@@ -12,18 +12,34 @@ struct CharacterItemView: View {
|
|||||||
|
|
||||||
let character: Character
|
let character: Character
|
||||||
let size: CGFloat
|
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 {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
KFImage(URL(string: character.imageUrl))
|
ZStack(alignment: .bottomLeading) {
|
||||||
.placeholder { Color.gray.opacity(0.2) }
|
KFImage(URL(string: character.imageUrl))
|
||||||
.retry(maxCount: 2, interval: .seconds(1))
|
.placeholder { Color.gray.opacity(0.2) }
|
||||||
.cancelOnDisappear(true)
|
.retry(maxCount: 2, interval: .seconds(1))
|
||||||
.resizable()
|
.cancelOnDisappear(true)
|
||||||
.scaledToFill()
|
.resizable()
|
||||||
.frame(width: size, height: size)
|
.scaledToFill()
|
||||||
.clipped()
|
.frame(width: size, height: size)
|
||||||
.cornerRadius(12)
|
.clipped()
|
||||||
|
.cornerRadius(12)
|
||||||
|
|
||||||
|
if isShowRank {
|
||||||
|
Text("\(rank)")
|
||||||
|
.font(.custom(Font.preBold.rawValue, size: 72))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.lineLimit(1)
|
||||||
|
.frame(height: capHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Text(character.name)
|
Text(character.name)
|
||||||
.font(.custom(Font.preRegular.rawValue, size: 18))
|
.font(.custom(Font.preRegular.rawValue, size: 18))
|
||||||
@@ -45,6 +61,8 @@ struct CharacterItemView: View {
|
|||||||
#Preview {
|
#Preview {
|
||||||
CharacterItemView(
|
CharacterItemView(
|
||||||
character: Character(characterId: 1, name: "찰리", description: "새로운 친구", imageUrl: "https://picsum.photos/300"),
|
character: Character(characterId: 1, name: "찰리", description: "새로운 친구", imageUrl: "https://picsum.photos/300"),
|
||||||
size: 168
|
size: 168,
|
||||||
|
rank: 20,
|
||||||
|
isShowRank: true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import SwiftUI
|
|||||||
struct CharacterSectionView: View {
|
struct CharacterSectionView: View {
|
||||||
let title: String
|
let title: String
|
||||||
let items: [Character]
|
let items: [Character]
|
||||||
|
let isShowRank: Bool
|
||||||
var onTap: (Character) -> Void = { _ in }
|
var onTap: (Character) -> Void = { _ in }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -25,7 +26,9 @@ struct CharacterSectionView: View {
|
|||||||
let item = items[idx]
|
let item = items[idx]
|
||||||
CharacterItemView(
|
CharacterItemView(
|
||||||
character: item,
|
character: item,
|
||||||
size: screenSize().width * 0.42
|
size: screenSize().width * 0.42,
|
||||||
|
rank: idx + 1,
|
||||||
|
isShowRank: isShowRank
|
||||||
)
|
)
|
||||||
.onTapGesture { onTap(item) }
|
.onTapGesture { onTap(item) }
|
||||||
}
|
}
|
||||||
@@ -42,7 +45,8 @@ struct CharacterSectionView: View {
|
|||||||
items: [
|
items: [
|
||||||
Character(characterId: 1, name: "찰리", description: "새로운 친구", imageUrl: "https://picsum.photos/300"),
|
Character(characterId: 1, name: "찰리", description: "새로운 친구", imageUrl: "https://picsum.photos/300"),
|
||||||
Character(characterId: 2, name: "데이지", description: "", imageUrl: "https://picsum.photos/300")
|
Character(characterId: 2, name: "데이지", description: "", imageUrl: "https://picsum.photos/300")
|
||||||
]
|
],
|
||||||
|
isShowRank: true
|
||||||
)
|
)
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color.black)
|
.background(Color.black)
|
||||||
|
|||||||
@@ -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 {
|
if !viewModel.newCharacters.isEmpty {
|
||||||
CharacterSectionView(
|
CharacterSectionView(
|
||||||
title: "신규 캐릭터",
|
title: "신규 캐릭터",
|
||||||
items: viewModel.newCharacters
|
items: viewModel.newCharacters,
|
||||||
|
isShowRank: false
|
||||||
) { ch in
|
) { ch in
|
||||||
onSelectCharacter(ch.characterId)
|
onSelectCharacter(ch.characterId)
|
||||||
}
|
}
|
||||||
@@ -52,7 +64,8 @@ struct CharacterView: View {
|
|||||||
let section = viewModel.curations[idx]
|
let section = viewModel.curations[idx]
|
||||||
CharacterSectionView(
|
CharacterSectionView(
|
||||||
title: section.title,
|
title: section.title,
|
||||||
items: section.characters
|
items: section.characters,
|
||||||
|
isShowRank: false
|
||||||
) { ch in
|
) { ch in
|
||||||
onSelectCharacter(ch.characterId)
|
onSelectCharacter(ch.characterId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ final class CharacterViewModel: ObservableObject {
|
|||||||
// MARK: - Published State
|
// MARK: - Published State
|
||||||
@Published private(set) var banners: [CharacterBannerResponse] = []
|
@Published private(set) var banners: [CharacterBannerResponse] = []
|
||||||
@Published private(set) var recentCharacters: [RecentCharacter] = []
|
@Published private(set) var recentCharacters: [RecentCharacter] = []
|
||||||
|
@Published private(set) var popularCharacters: [Character] = []
|
||||||
@Published private(set) var newCharacters: [Character] = []
|
@Published private(set) var newCharacters: [Character] = []
|
||||||
@Published private(set) var curations: [CurationSection] = []
|
@Published private(set) var curations: [CurationSection] = []
|
||||||
|
|
||||||
@@ -47,6 +48,7 @@ final class CharacterViewModel: ObservableObject {
|
|||||||
if let data = decoded.data, decoded.success {
|
if let data = decoded.data, decoded.success {
|
||||||
self.banners = data.banners
|
self.banners = data.banners
|
||||||
self.recentCharacters = data.recentCharacters
|
self.recentCharacters = data.recentCharacters
|
||||||
|
self.popularCharacters = data.popularCharacters
|
||||||
self.newCharacters = data.newCharacters
|
self.newCharacters = data.newCharacters
|
||||||
self.curations = data.curationSections.filter { !$0.characters.isEmpty }
|
self.curations = data.curationSections.filter { !$0.characters.isEmpty }
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -87,7 +87,9 @@ struct CharacterDetailView: View {
|
|||||||
description: otherCharacter.tags,
|
description: otherCharacter.tags,
|
||||||
imageUrl: otherCharacter.imageUrl
|
imageUrl: otherCharacter.imageUrl
|
||||||
),
|
),
|
||||||
size: screenSize().width * 0.42
|
size: screenSize().width * 0.42,
|
||||||
|
rank: 0,
|
||||||
|
isShowRank: false
|
||||||
)
|
)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
// 캐릭터 변경 후 스크롤을 최상단으로 이동
|
// 캐릭터 변경 후 스크롤을 최상단으로 이동
|
||||||
|
|||||||
@@ -105,34 +105,33 @@ final class ChatRoomViewModel: ObservableObject {
|
|||||||
|
|
||||||
showSendingMessage = true
|
showSendingMessage = true
|
||||||
repository.sendMessage(roomId: roomId, message: message)
|
repository.sendMessage(roomId: roomId, message: message)
|
||||||
.sink { result in
|
.receive(on: DispatchQueue.main)
|
||||||
switch result {
|
.sink { [weak self] completion in
|
||||||
|
guard let self = self else { return }
|
||||||
|
switch completion {
|
||||||
case .finished:
|
case .finished:
|
||||||
DEBUG_LOG("finish")
|
DEBUG_LOG("finish")
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
|
self.showSendingMessage = false // 실패 시 복구
|
||||||
|
self.errorMessage = error.localizedDescription
|
||||||
|
self.isShowPopup = true
|
||||||
ERROR_LOG(error.localizedDescription)
|
ERROR_LOG(error.localizedDescription)
|
||||||
}
|
}
|
||||||
} receiveValue: { [unowned self] response in
|
} receiveValue: { [weak self] response in
|
||||||
|
guard let self = self else { return }
|
||||||
let responseData = response.data
|
let responseData = response.data
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let jsonDecoder = JSONDecoder()
|
let jsonDecoder = JSONDecoder()
|
||||||
let decoded = try jsonDecoder.decode(ApiResponse<SendChatMessageResponse>.self, from: responseData)
|
let decoded = try jsonDecoder.decode(ApiResponse<SendChatMessageResponse>.self, from: responseData)
|
||||||
|
|
||||||
if let data = decoded.data, decoded.success {
|
if let data = decoded.data, decoded.success {
|
||||||
self.messages.append(contentsOf: data.messages)
|
self.messages.append(contentsOf: data.messages)
|
||||||
self.updateQuota(nextRechargeAtEpoch: data.nextRechargeAtEpoch)
|
self.updateQuota(nextRechargeAtEpoch: data.nextRechargeAtEpoch)
|
||||||
} else {
|
} else {
|
||||||
if let message = decoded.message {
|
self.errorMessage = decoded.message ??
|
||||||
self.errorMessage = message
|
"다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
|
||||||
} else {
|
|
||||||
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
|
|
||||||
}
|
|
||||||
|
|
||||||
self.isShowPopup = true
|
self.isShowPopup = true
|
||||||
}
|
}
|
||||||
|
self.showSendingMessage = false // 성공 시 종료
|
||||||
self.showSendingMessage = false
|
|
||||||
} catch {
|
} catch {
|
||||||
self.showSendingMessage = false
|
self.showSendingMessage = false
|
||||||
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
|
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
|
||||||
|
|||||||
Reference in New Issue
Block a user