feat(character): 캐릭터 메인 화면 구현 및 Combine 기반 리팩터링

- 배너/최근/신규/큐레이션 섹션 UI 구성 및 데이터 바인딩
- 네트워크 이미지 로더를 Kingfisher(KFImage)로 교체하여 캐싱/재시도 지원
- CharacterApi에 토큰 헤더 포함, GET /api/chat/character/main 연동
This commit is contained in:
Yu Sung
2025-08-29 20:58:57 +09:00
parent 1488ed5b89
commit 4dd1866169
15 changed files with 528 additions and 9 deletions

View File

@@ -0,0 +1,61 @@
//
// AutoSlideCharacterBannerView.swift
// SodaLive
//
// Created by klaus on 8/29/25.
//
import SwiftUI
import Kingfisher
struct AutoSlideCharacterBannerView: View {
var items: [CharacterBannerResponse] = []
var onTap: (CharacterBannerResponse) -> Void = { _ in }
@State private var index: Int = 0
@State private var height: CGFloat = 0
private let timer = Timer.publish(every: 4, on: .main, in: .common).autoconnect()
var body: some View {
TabView(selection: $index) {
ForEach(0..<items.count, id: \.self) { index in
let item = items[index]
KFImage(URL(string: item.imageUrl))
.placeholder { Color.gray.opacity(0.2) }
.retry(maxCount: 2, interval: .seconds(1))
.cancelOnDisappear(true)
.resizable()
.scaledToFill()
.frame(height: height)
.clipped()
.cornerRadius(12)
.contentShape(Rectangle())
.tag(index)
.onTapGesture { onTap(item) }
}
}
.tabViewStyle(.page(indexDisplayMode: .automatic))
.frame(maxWidth: .infinity)
.frame(height: height)
.onAppear {
self.height = screenSize().width * 0.53
}
.onDisappear {
timer.upstream.connect().cancel()
}
.onReceive(timer) { _ in
guard !items.isEmpty else { return }
withAnimation { index = (index + 1) % items.count }
}
}
}
#Preview {
AutoSlideCharacterBannerView(
items: [
CharacterBannerResponse(characterId: 1, imageUrl: "https://picsum.photos/1000/300")
]
)
.padding()
.background(Color.black)
}

View File

@@ -0,0 +1,11 @@
//
// CharacterBannerResponse.swift
// SodaLive
//
// Created by klaus on 8/29/25.
//
struct CharacterBannerResponse: Decodable {
let characterId: Int
let imageUrl: String
}

View File

@@ -0,0 +1,13 @@
//
// Character.swift
// SodaLive
//
// Created by klaus on 8/29/25.
//
struct Character: Decodable {
let characterId: Int
let name: String
let description: String?
let imageUrl: String
}

View File

@@ -0,0 +1,32 @@
//
// CharacterApi.swift
// SodaLive
//
// Created by klaus on 8/29/25.
//
import Foundation
import Moya
enum CharacterApi {
case getCharacterHome
}
extension CharacterApi: TargetType {
var baseURL: URL { URL(string: BASE_URL)! }
var path: String {
switch self {
case .getCharacterHome:
return "/api/chat/character/main"
}
}
var method: Moya.Method { .get }
var task: Moya.Task { .requestPlain }
var headers: [String : String]? {
["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"]
}
}

View File

@@ -0,0 +1,14 @@
//
// CharacterHomeResponse.swift
// SodaLive
//
// Created by klaus on 8/29/25.
//
struct CharacterHomeResponse: Decodable {
let banners: [CharacterBannerResponse]
let recentCharacters: [RecentCharacter]
let popularCharacters: [Character]
let newCharacters: [Character]
let curationSections: [CurationSection]
}

View File

@@ -0,0 +1,50 @@
//
// CharacterItemView.swift
// SodaLive
//
// Created by klaus on 8/29/25.
//
import SwiftUI
import Kingfisher
struct CharacterItemView: View {
let character: Character
let size: CGFloat
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)
Text(character.name)
.font(.custom(Font.preRegular.rawValue, size: 18))
.foregroundColor(.white)
.lineLimit(1)
.truncationMode(.tail)
if let desc = character.description, !desc.isEmpty {
Text(desc)
.font(.custom(Font.preRegular.rawValue, size: 14))
.foregroundColor(Color(hex: "78909C"))
.lineLimit(1)
}
}
.frame(width: size, alignment: .leading)
}
}
#Preview {
CharacterItemView(
character: Character(characterId: 1, name: "찰리", description: "새로운 친구", imageUrl: "https://picsum.photos/300"),
size: 168
)
}

View File

@@ -0,0 +1,19 @@
//
// CharacterRepository.swift
// SodaLive
//
// Created by klaus on 8/29/25.
//
import Foundation
import CombineMoya
import Combine
import Moya
class CharacterRepository {
private let api = MoyaProvider<CharacterApi>()
func getCharacterMain() -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.getCharacterHome)
}
}

View File

@@ -0,0 +1,49 @@
//
// CharacterSectionView.swift
// SodaLive
//
// Created by klaus on 8/29/25.
//
import SwiftUI
struct CharacterSectionView: View {
let title: String
let items: [Character]
var onTap: (Character) -> Void = { _ in }
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(title)
.font(.custom(Font.preBold.rawValue, size: 20))
.foregroundColor(.white)
.padding(.horizontal, 24)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
ForEach(items.indices, id: \.self) { idx in
let item = items[idx]
CharacterItemView(
character: item,
size: screenSize().width * 0.42
)
.onTapGesture { onTap(item) }
}
}
.padding(.horizontal, 24)
}
}
}
}
#Preview {
CharacterSectionView(
title: "신규 캐릭터",
items: [
Character(characterId: 1, name: "찰리", description: "새로운 친구", imageUrl: "https://picsum.photos/300"),
Character(characterId: 2, name: "데이지", description: "", imageUrl: "https://picsum.photos/300")
]
)
.padding()
.background(Color.black)
}

View File

@@ -8,16 +8,84 @@
import SwiftUI
struct CharacterView: View {
@StateObject var viewModel = CharacterViewModel()
private let horizontalPadding: CGFloat = 16
var body: some View {
VStack(spacing: 12) {
BaseView(isLoading: $viewModel.isLoading) {
ScrollView(.vertical, showsIndicators: false) {
VStack(alignment: .leading, spacing: 24) {
//
if !viewModel.banners.isEmpty {
AutoSlideCharacterBannerView(items: viewModel.banners) { banner in
DEBUG_LOG("Banner tapped: \(banner.characterId)")
}
}
//
if !viewModel.recentCharacters.isEmpty {
RecentCharacterSectionView(
titleCount: viewModel.recentCharacters.count,
items: viewModel.recentCharacters
) { ch in
DEBUG_LOG("Recent tapped: \(ch.characterId)")
}
}
//
if !viewModel.newCharacters.isEmpty {
CharacterSectionView(
title: "신규 캐릭터",
items: viewModel.newCharacters
) { ch in
DEBUG_LOG("New tapped: \(ch.characterId)")
}
}
// ( )
if !viewModel.curations.isEmpty {
VStack(alignment: .leading, spacing: 24) {
ForEach(viewModel.curations.indices, id: \.self) { idx in
let section = viewModel.curations[idx]
CharacterSectionView(
title: section.title,
items: section.characters
) { ch in
DEBUG_LOG("Curation tapped: \\(ch.characterId)")
}
}
}
}
}
.padding(.vertical, 24)
}
.background(Color.black.ignoresSafeArea())
.onAppear {
if !viewModel.isLoading {
viewModel.getCharacterMain()
}
}
}
.popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .top, autohideIn: 2) {
GeometryReader { geo in
HStack {
Spacer()
Text("캐릭터 페이지 (준비중)")
.font(.custom(Font.preMedium.rawValue, size: 16))
Text(viewModel.errorMessage)
.padding(.vertical, 13.3)
.frame(width: geo.size.width - 66.7, alignment: .center)
.font(.custom(Font.medium.rawValue, size: 12))
.background(Color.button)
.foregroundColor(Color.white)
.multilineTextAlignment(.center)
.cornerRadius(20)
.padding(.top, 66.7)
Spacer()
}
}
}
}
}
#Preview {
CharacterView()

View File

@@ -0,0 +1,69 @@
//
// CharacterViewModel.swift
// SodaLive
//
// Created by klaus on 8/29/25.
//
import Foundation
import Combine
import Moya
final class CharacterViewModel: ObservableObject {
// MARK: - Published State
@Published private(set) var banners: [CharacterBannerResponse] = []
@Published private(set) var recentCharacters: [RecentCharacter] = []
@Published private(set) var newCharacters: [Character] = []
@Published private(set) var curations: [CurationSection] = []
@Published var isLoading: Bool = false
@Published var errorMessage: String = ""
@Published var isShowPopup = false
// MARK: - Private
private let repository = CharacterRepository()
private var subscription = Set<AnyCancellable>()
// MARK: - API
func getCharacterMain() {
self.isLoading = true
repository.getCharacterMain()
.sink { result in
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
}
} receiveValue: { response in
let responseData = response.data
self.isLoading = false
do {
let jsonDecoder = JSONDecoder()
let decoded = try jsonDecoder.decode(ApiResponse<CharacterHomeResponse>.self, from: responseData)
if let data = decoded.data, decoded.success {
self.banners = data.banners
self.recentCharacters = data.recentCharacters
self.newCharacters = data.newCharacters
self.curations = data.curationSections.filter { !$0.characters.isEmpty }
} else {
if let message = decoded.message {
self.errorMessage = message
} else {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
}
self.isShowPopup = true
}
} catch {
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
self.isShowPopup = true
}
}
.store(in: &subscription)
}
}

View File

@@ -0,0 +1,12 @@
//
// CurationSection.swift
// SodaLive
//
// Created by klaus on 8/29/25.
//
struct CurationSection: Decodable {
let characterCurationId: Int
let title: String
let characters: [Character]
}

View File

@@ -0,0 +1,18 @@
//
// CharacterDetailView.swift
// SodaLive
//
// Created by klaus on 8/29/25.
//
import SwiftUI
struct CharacterDetailView: View {
var body: some View {
Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
}
}
#Preview {
CharacterDetailView()
}

View File

@@ -0,0 +1,12 @@
//
// RecentCharacter.swift
// SodaLive
//
// Created by klaus on 8/29/25.
//
struct RecentCharacter: Decodable {
let characterId: Int
let name: String
let imageUrl: String
}

View File

@@ -0,0 +1,40 @@
//
// RecentCharacterItemView.swift
// SodaLive
//
// Created by klaus on 8/29/25.
//
import SwiftUI
import Kingfisher
struct RecentCharacterItemView: View {
let character: RecentCharacter
var body: some View {
VStack(spacing: 6) {
KFImage(URL(string: character.imageUrl))
.placeholder { Circle().fill(Color.gray.opacity(0.2)) }
.retry(maxCount: 2, interval: .seconds(1))
.cancelOnDisappear(true)
.resizable()
.scaledToFill()
.frame(width: 76, height: 76)
.clipShape(Circle())
Text(character.name)
.font(.custom(Font.preRegular.rawValue, size: 18))
.foregroundColor(.white)
.lineLimit(1)
.frame(maxWidth: 76)
.multilineTextAlignment(.center)
}
}
}
#Preview {
RecentCharacterItemView(
character: RecentCharacter(characterId: 1, name: "앨리스", imageUrl: "https://picsum.photos/200")
)
.background(Color.black)
}

View File

@@ -0,0 +1,51 @@
//
// RecentCharacterSectionView.swift
// SodaLive
//
// Created by klaus on 8/29/25.
//
import SwiftUI
struct RecentCharacterSectionView: View {
let titleCount: Int
let items: [RecentCharacter]
var onTap: (RecentCharacter) -> Void = { _ in }
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 0) {
Text("최근 대화한 캐릭터 ")
.font(.custom(Font.preBold.rawValue, size: 20))
.foregroundColor(.white)
Text("\(titleCount)")
.font(.custom(Font.preBold.rawValue, size: 20))
.foregroundColor(Color(hex: "FDCA2F"))
Spacer()
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
ForEach(items.indices, id: \.self) { idx in
let item = items[idx]
RecentCharacterItemView(character: item)
.onTapGesture { onTap(item) }
}
}
}
}
}
}
#Preview {
RecentCharacterSectionView(
titleCount: 3,
items: [
RecentCharacter(characterId: 1, name: "라라", imageUrl: "https://picsum.photos/200"),
RecentCharacter(characterId: 2, name: "마리", imageUrl: "https://picsum.photos/200"),
RecentCharacter(characterId: 3, name: "Nana", imageUrl: "https://picsum.photos/200")
]
)
.padding()
.background(Color.black)
}