feat(character): 인기 캐릭터 섹션 추가
This commit is contained in:
		@@ -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
 | 
			
		||||
    )
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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)
 | 
			
		||||
 
 | 
			
		||||
@@ -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)
 | 
			
		||||
                                }
 | 
			
		||||
 
 | 
			
		||||
@@ -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 {
 | 
			
		||||
 
 | 
			
		||||
@@ -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 {
 | 
			
		||||
                                                            // 캐릭터 변경 후 스크롤을 최상단으로 이동
 | 
			
		||||
 
 | 
			
		||||
@@ -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<SendChatMessageResponse>.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계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user