feat(chat-original): 원작 상세 화면 및 캐릭터 무한 스크롤 로딩 구현
This commit is contained in:
@@ -167,4 +167,6 @@ enum AppStep {
|
||||
case chatRoom(id: Int)
|
||||
|
||||
case newCharacterAll
|
||||
|
||||
case originalWorkDetail(originalId: Int)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
//
|
||||
// OriginalWorkCharactersPageResponse.swift
|
||||
// SodaLive
|
||||
//
|
||||
// Created by klaus on 9/15/25.
|
||||
//
|
||||
|
||||
struct OriginalWorkCharactersPageResponse: Decodable {
|
||||
let totalCount: Int
|
||||
let content: [Character]
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//
|
||||
// OriginalWorkDetailHeaderView.swift
|
||||
// SodaLive
|
||||
//
|
||||
// Created by klaus on 9/16/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Kingfisher
|
||||
|
||||
struct OriginalWorkDetailHeaderView: View {
|
||||
|
||||
let item: OriginalWorkDetailResponse
|
||||
|
||||
@State var isDescriptionExpanded = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if let imageUrl = item.imageUrl {
|
||||
KFImage(URL(string: imageUrl))
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 168, height: 168 * 432 / 306)
|
||||
.clipped()
|
||||
.cornerRadius(16)
|
||||
}
|
||||
|
||||
Text(item.title)
|
||||
.font(.custom(Font.preBold.rawValue, size: 26))
|
||||
.foregroundColor(.white)
|
||||
.padding(.top, 40)
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Text(item.contentType)
|
||||
.font(.custom(Font.preRegular.rawValue, size: 14))
|
||||
.foregroundColor(Color(hex: "B0BEC5"))
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 3)
|
||||
.background(Color(hex: "263238"))
|
||||
.cornerRadius(4)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.strokeBorder(lineWidth: 1)
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
|
||||
Text(item.category)
|
||||
.font(.custom(Font.preRegular.rawValue, size: 14))
|
||||
.foregroundColor(.button)
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 3)
|
||||
.background(Color(hex: "263238"))
|
||||
.cornerRadius(4)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.strokeBorder(lineWidth: 1)
|
||||
.foregroundColor(.button)
|
||||
}
|
||||
|
||||
if item.isAdult {
|
||||
Text("19+")
|
||||
.font(.custom(Font.preRegular.rawValue, size: 14))
|
||||
.foregroundColor(Color(hex: "ff5c49"))
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 3)
|
||||
.background(Color(hex: "263238"))
|
||||
.cornerRadius(4)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.strokeBorder(lineWidth: 1)
|
||||
.foregroundColor(Color(hex: "ff5c49"))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, 14)
|
||||
|
||||
Text(item.description)
|
||||
.font(.custom(Font.preRegular.rawValue, size: 14))
|
||||
.foregroundColor(Color(hex: "cfd8dc"))
|
||||
.lineLimit(!isDescriptionExpanded ? 2 : Int.max)
|
||||
.truncationMode(.tail)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 14)
|
||||
.onTapGesture {
|
||||
isDescriptionExpanded.toggle()
|
||||
}
|
||||
|
||||
Text("원작 보러가기")
|
||||
.font(.custom(Font.preBold.rawValue, size: 16))
|
||||
.foregroundColor(.button)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 15)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.strokeBorder(lineWidth: 1)
|
||||
.foregroundColor(.button)
|
||||
}
|
||||
.padding(.top, 24)
|
||||
.onTapGesture {
|
||||
if let link = item.originalLink, let url = URL(string: link) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
OriginalWorkDetailHeaderView(
|
||||
item: OriginalWorkDetailResponse(
|
||||
imageUrl: "https://picsum.photos/400",
|
||||
title: "작품제목",
|
||||
contentType: "웹소설",
|
||||
category: "로맨스",
|
||||
isAdult: true,
|
||||
description: "작품설명입니다.보이스온의 오픈월드 캐릭터톡은 청소년 보호를 위해 본인인증한성인만 이용이 가능합니다.캐릭터톡 서비스를 이용하시려면 본인인증을 하고 이용해주세요.",
|
||||
originalLink: "https://apple.com",
|
||||
characters: []
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//
|
||||
// OriginalWorkDetailResponse.swift
|
||||
// SodaLive
|
||||
//
|
||||
// Created by klaus on 9/15/25.
|
||||
//
|
||||
|
||||
struct OriginalWorkDetailResponse: Decodable {
|
||||
let imageUrl: String?
|
||||
let title: String
|
||||
let contentType: String
|
||||
let category: String
|
||||
let isAdult: Bool
|
||||
let description: String
|
||||
let originalLink: String?
|
||||
let characters: [Character]
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//
|
||||
// OriginalWorkDetailView.swift
|
||||
// SodaLive
|
||||
//
|
||||
// Created by klaus on 9/15/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Kingfisher
|
||||
|
||||
struct OriginalWorkDetailView: View {
|
||||
|
||||
@StateObject var viewModel = OriginalWorkDetailViewModel()
|
||||
|
||||
let originalId: Int
|
||||
|
||||
private let horizontalPadding: CGFloat = 12
|
||||
private let gridSpacing: CGFloat = 12
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
BaseView(isLoading: $viewModel.isLoading) {
|
||||
ZStack(alignment: .top) {
|
||||
if let imageUrl = viewModel.response?.imageUrl {
|
||||
KFImage(URL(string: imageUrl))
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(maxWidth: .infinity)
|
||||
.blur(radius: 25)
|
||||
}
|
||||
|
||||
Color.black.opacity(0.5).ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 0) {
|
||||
Image("ic_back")
|
||||
.resizable()
|
||||
.frame(width: 24, height: 24)
|
||||
.onTapGesture {
|
||||
AppState.shared.back()
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.frame(height: 56)
|
||||
|
||||
if let response = viewModel.response {
|
||||
GeometryReader { geo in
|
||||
let totalSpacing: CGFloat = gridSpacing * 2
|
||||
let width = (geo.size.width - (horizontalPadding * 2) - totalSpacing) / 3
|
||||
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
VStack(spacing: 0) {
|
||||
OriginalWorkDetailHeaderView(item: response)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 24)
|
||||
|
||||
LazyVGrid(
|
||||
columns: Array(
|
||||
repeating: GridItem(
|
||||
.flexible(),
|
||||
spacing: gridSpacing,
|
||||
alignment: .topLeading
|
||||
),
|
||||
count: 3
|
||||
),
|
||||
alignment: .leading,
|
||||
spacing: gridSpacing
|
||||
) {
|
||||
ForEach(viewModel.characters.indices, id: \.self) { idx in
|
||||
let item = viewModel.characters[idx]
|
||||
|
||||
NavigationLink(value: item.characterId) {
|
||||
CharacterItemView(
|
||||
character: item,
|
||||
size: width,
|
||||
rank: 0,
|
||||
isShowRank: false
|
||||
)
|
||||
.onAppear { viewModel.loadMoreIfNeeded(currentIndex: idx) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
|
||||
if viewModel.isLoadingMore {
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
.padding(.vertical, 16)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if viewModel.response == nil {
|
||||
viewModel.originalId = originalId
|
||||
}
|
||||
}
|
||||
.navigationDestination(for: Int.self) { characterId in
|
||||
CharacterDetailView(characterId: characterId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
OriginalWorkDetailView(originalId: 0)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//
|
||||
// OriginalWorkDetailViewModel.swift
|
||||
// SodaLive
|
||||
//
|
||||
// Created by klaus on 9/15/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import Moya
|
||||
|
||||
final class OriginalWorkDetailViewModel: ObservableObject {
|
||||
@Published var isLoading = false
|
||||
@Published var errorMessage = ""
|
||||
@Published var isShowPopup = false
|
||||
@Published var isLoadingMore: Bool = false
|
||||
|
||||
@Published private(set) var characters: [Character] = []
|
||||
@Published private(set) var totalCount: Int = 0
|
||||
@Published private(set) var response: OriginalWorkDetailResponse? = nil
|
||||
|
||||
private let repository = OriginalWorkRepository()
|
||||
private var subscription = Set<AnyCancellable>()
|
||||
private var currentPage: Int = 0
|
||||
private var hasMorePages: Bool = true
|
||||
|
||||
var originalId: Int = 0 {
|
||||
didSet {
|
||||
fetchDetail()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - API
|
||||
func loadMoreIfNeeded(currentIndex: Int) {
|
||||
guard hasMorePages,
|
||||
!isLoading,
|
||||
!isLoadingMore,
|
||||
currentIndex >= characters.count - 3 else { return }
|
||||
fetchCharacters()
|
||||
}
|
||||
|
||||
private func fetchDetail() {
|
||||
isLoading = true
|
||||
|
||||
repository.getOriginalDetail(id: originalId)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] completion in
|
||||
switch completion {
|
||||
case .finished:
|
||||
DEBUG_LOG("finish")
|
||||
case .failure(let error):
|
||||
ERROR_LOG(error.localizedDescription)
|
||||
self?.isLoading = false
|
||||
self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
|
||||
self?.isShowPopup = true
|
||||
}
|
||||
} receiveValue: { [weak self] response in
|
||||
guard let self = self else { return }
|
||||
do {
|
||||
let jsonDecoder = JSONDecoder()
|
||||
let decoded = try jsonDecoder.decode(ApiResponse<OriginalWorkDetailResponse>.self, from: response.data)
|
||||
if let data = decoded.data, decoded.success {
|
||||
self.response = data
|
||||
self.characters = data.characters
|
||||
self.isLoading = false
|
||||
} else {
|
||||
if let message = decoded.message {
|
||||
self.errorMessage = message
|
||||
} else {
|
||||
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
|
||||
}
|
||||
self.isShowPopup = true
|
||||
self.isLoading = false
|
||||
}
|
||||
} catch {
|
||||
self.isLoading = false
|
||||
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
|
||||
self.isShowPopup = true
|
||||
}
|
||||
}
|
||||
.store(in: &subscription)
|
||||
}
|
||||
|
||||
private func fetchCharacters() {
|
||||
currentPage += 1
|
||||
isLoadingMore = true
|
||||
|
||||
repository.getOriginalWorkCharacters(id: originalId, page: currentPage)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] completion in
|
||||
switch completion {
|
||||
case .finished:
|
||||
DEBUG_LOG("finish")
|
||||
case .failure(let error):
|
||||
ERROR_LOG(error.localizedDescription)
|
||||
self?.isLoadingMore = false
|
||||
self?.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
|
||||
self?.isShowPopup = true
|
||||
}
|
||||
} receiveValue: { [weak self] response in
|
||||
guard let self = self else { return }
|
||||
do {
|
||||
let jsonDecoder = JSONDecoder()
|
||||
let decoded = try jsonDecoder.decode(ApiResponse<OriginalWorkCharactersPageResponse>.self, from: response.data)
|
||||
if let data = decoded.data, decoded.success {
|
||||
self.totalCount = data.totalCount
|
||||
|
||||
if !data.content.isEmpty {
|
||||
self.characters.append(contentsOf: data.content)
|
||||
} else {
|
||||
hasMorePages = false
|
||||
}
|
||||
|
||||
self.isLoadingMore = false
|
||||
} else {
|
||||
if let message = decoded.message {
|
||||
self.errorMessage = message
|
||||
} else {
|
||||
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
|
||||
}
|
||||
self.isShowPopup = true
|
||||
self.isLoadingMore = false
|
||||
}
|
||||
} catch {
|
||||
self.isLoading = false
|
||||
self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다."
|
||||
self.isShowPopup = true
|
||||
}
|
||||
}
|
||||
.store(in: &subscription)
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,10 @@ struct OriginalTabView: View {
|
||||
size: width
|
||||
)
|
||||
.onAppear { viewModel.loadMoreIfNeeded(currentIndex: idx) }
|
||||
.onTapGesture {
|
||||
AppState.shared
|
||||
.setAppStep(step: .originalWorkDetail(originalId: item.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
|
||||
@@ -257,6 +257,9 @@ struct ContentView: View {
|
||||
case .newCharacterAll:
|
||||
NewCharacterListView()
|
||||
|
||||
case .originalWorkDetail(let originalId):
|
||||
OriginalWorkDetailView(originalId: originalId)
|
||||
|
||||
default:
|
||||
EmptyView()
|
||||
.frame(width: 0, height: 0, alignment: .topLeading)
|
||||
|
||||
Reference in New Issue
Block a user